How to Create a Space Invaders Game in Python

Master game development with Python in this engaging tutorial, where you'll learn how to create your own Space Invaders game using Pygame. From setting up your environment to crafting game mechanics with ships, aliens, and bullets, this guide is packed with practical steps and essential coding insights. Ideal for begin
  · 16 min read · Updated nov 2023 · Game Development

Welcome! Meet our Python Code Assistant, your new coding buddy. Why wait? Start exploring now!

Space Invaders is a classic arcade game that has entertained gamers for decades, and now you can build your version of this iconic title.

In this tutorial, we will cover the essentials of game development with Pygame, from setting up your development environment to creating the game's core mechanics. By the end of this tutorial, you'll have a fully functional Space Invaders game that you can customize and expand upon to make it your own. Let's dive into the first step: setting up your development environment.

Installation and Setup

Let's start by making sure Pygame is installed on your computer. Head to your terminal and install pygame module using pip:

$ pip install pygame

After that, create a directory for the game and create the following .py file inside it; settings.py, main.py, world.py, display.py, alien.py, ship.py, and bullet.py. Create a folder inside our game directory and call it assets, where we will store our game assets, such as sprites. Here is the file structure of our code:

Now, we can start coding. Let's define our game variables in settings.py:

# /* settings.py
WIDTH, HEIGHT = 720, 450

SPACE = 30
FONT_SIZE = 20
EVENT_FONT_SIZE = 60
NAV_THICKNESS = 50
CHARACTER_SIZE  = 30
PLAYER_SPEED = 10
ENEMY_SPEED = 1 
BULLET_SPEED = 15 # for both sides
BULLET_SIZE = 10

You can always tweak these parameters based on your needs. Next, let's create the main class of our game. This class will be responsible for calling and running the game loop:

# /* main.py
import pygame, sys
from settings import WIDTH, HEIGHT, NAV_THICKNESS
from world import World

pygame.init()

screen = pygame.display.set_mode((WIDTH, HEIGHT + NAV_THICKNESS))
pygame.display.set_caption("Space Invader")

class Main:
    def __init__(self, screen):
        self.screen = screen
        self.FPS = pygame.time.Clock()

    def main(self):
        world = World(self.screen)
        while True:
            self.screen.fill("black")
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    sys.exit()
                if event.type == pygame.KEYDOWN:
                    if event.key == pygame.K_SPACE:
                        world.player_move(attack = True)
            world.player_move()
            world.update()
            pygame.display.update()
            self.FPS.tick(30)

if __name__ == "__main__":
    play = Main(screen)
    play.main()

From the name itself, the Main class will be the main class of our game. It takes an argument of screen which will serve as the game window for animating the game. The main() function will run and update our game. It will initialize the World first (our game world). To keep the game running without intentionally exiting, we put a while loop inside it. Inside our loop, we place another loop (the for loop), which will catch all the events going on inside our game window, especially when the player hits the exit button.

Adding Game Characters

The Ship

Let's create a class for game characters, the Ship class for the player's character, and the Alien class for the opponents of the player's character. Let's create the Ship class first:

# /* ship.py
import pygame
from settings import PLAYER_SPEED, BULLET_SIZE
from bullet import Bullet

class Ship(pygame.sprite.Sprite):
    def __init__(self, pos, size):
        super().__init__()
        self.x = pos[0]
        self.y = pos[1]
        # ship info 
        img_path = 'assets/ship/ship.png'
        self.image = pygame.image.load(img_path)
        self.image = pygame.transform.scale(self.image, (size, size))
        self.rect = self.image.get_rect(topleft = pos)
        self.mask = pygame.mask.from_surface(self.image)
        self.ship_speed = PLAYER_SPEED
        # ship status
        self.life = 3
        self.player_bullets = pygame.sprite.Group()

    def move_left(self):
        self.rect.x -= self.ship_speed

    def move_up(self):
        self.rect.y -= self.ship_speed

    def move_right(self):
        self.rect.x += self.ship_speed

    def move_bottom(self):
        self.rect.y += self.ship_speed

    def _shoot(self):
        specific_pos = (self.rect.centerx - (BULLET_SIZE // 2), self.rect.y)
        self.player_bullets.add(Bullet(specific_pos, BULLET_SIZE, "player"))

    def update(self):
        self.rect = self.image.get_rect(topleft=(self.rect.x, self.rect.y))

We start by importing the pygame module and variables PLAYER_SPEED, and BULLET_SIZE in settings.py. We also import the Bullet class, which we'll create later.

The Ship class represents the player's spaceship in the game. It inherits from pygame.sprite.Sprite, indicating that it's a game object that can be easily managed within Pygame. The constructor __init__() sets up the ship's initial properties, such as its position, image, mask, speed, and other attributes.

The ship's position and collision mask are also initialized. The speed of the ship is set to the value defined in PLAYER_SPEED. The ship's life is initialized to 3, and a group for player bullets is created. The functions move_left(), move_up(), move_right(), and move_bottom() are responsible for the ship's movement and the _shoot(), from the name itself, is responsible for shooting. To apply all the changes in movement, we have the update() function to do so.

The Aliens

Now let's move on to creating the class for our opponents, the Alien class:

# /* alien.py
import pygame
from settings import BULLET_SIZE
from bullet import Bullet

class Alien(pygame.sprite.Sprite):
    def __init__(self, pos, size, row_num):
        super().__init__()
        self.x = pos[0]
        self.y = pos[1]
        # alien info
        img_path = f'assets/aliens/{row_num}.png'
        self.image = pygame.image.load(img_path)
        self.image = pygame.transform.scale(self.image, (size, size))
        self.rect = self.image.get_rect(topleft = pos)
        self.mask = pygame.mask.from_surface(self.image)
        self.move_speed = 5
        self.to_direction = "right"
        # alien status
        self.bullets = pygame.sprite.GroupSingle()

    def move_left(self):
        self.rect.x -= self.move_speed

    def move_right(self):
        self.rect.x += self.move_speed

    def move_bottom(self):
        self.rect.y += self.move_speed

    def _shoot(self):
        specific_pos = (self.rect.centerx - (BULLET_SIZE // 2), self.rect.centery)
        self.bullets.add(Bullet(specific_pos, BULLET_SIZE, "enemy"))

    def update(self):
        self.rect = self.image.get_rect(topleft=(self.rect.x, self.rect.y))

The Alien class works the same as the Ship class. The only difference between them is they have different movement speeds, no ability to move backward, and can only shoot one after the other explodes or out of the frame.

The Bullets

Now, let's move on to making the Bullet class:

# /* bullet.py
import pygame
from settings import BULLET_SPEED, HEIGHT

class Bullet(pygame.sprite.Sprite):
    def __init__(self, pos, size, side):
        super().__init__()
        self.x = pos[0]
        self.y = pos[1]
        # bullet info
        img_path = f'assets/bullet/{side}-bullet.png'
        self.image = pygame.image.load(img_path)
        self.image = pygame.transform.scale(self.image, (size, size))
        self.rect = self.image.get_rect(topleft = pos)
        self.mask = pygame.mask.from_surface(self.image)
        # different bullet movement direction for both player and enemy (alien)
        if side == "enemy":
            self.move_speed = BULLET_SPEED
        elif side == "player":
            self.move_speed = (- BULLET_SPEED)

    def _move_bullet(self):
        self.rect.y += self.move_speed

    def update(self):
        self._move_bullet()
        self.rect = self.image.get_rect(topleft=(self.rect.x, self.rect.y))
        # delete the bullet if it get through out of the screen
        if self.rect.bottom <= 0 or self.rect.top >= HEIGHT:
            self.kill()

The Bullet class is responsible for managing the bullets fired by the player and enemy ships.

Inside the Bullet class constructor (__init__()), several properties are set up. These properties include the bullet's position, image, collision mask, and movement speed.

The image of the bullet is loaded from a file path based on the "side" parameter. For example, if it's a player bullet, it loads 'assets/bullet/player-bullet.png'. The image is then scaled to the specified size, and the initial position and collision mask are configured. The direction of the bullet's movement is determined based on its "side".

The _move_bullet() method is responsible for updating the bullet's position during the game. It adjusts the bullet's vertical (y) coordinate based on its movement speed, which controls its direction. If it's a player bullet, it moves upward; if it's an enemy bullet, it moves downward. The update() method is called regularly in the game loop to update the bullet's position and check if it has moved out of the visible screen area. If the bullet goes beyond the screen boundaries (either above or below), it is removed from the game using the kill() method. This ensures that bullets that have fulfilled their purpose or missed their target are not visible or active in the game, maintaining a clean and efficient game environment.

Creating the Game World

Let's move on to making the World class. The World class coordinates various game elements, controls gameplay mechanics, and ensures that the game responds to player input, handles collisions, and progresses through levels:

# /* world.py
import pygame
from ship import Ship
from alien import Alien
from settings import HEIGHT, WIDTH, ENEMY_SPEED, CHARACTER_SIZE, BULLET_SIZE, NAV_THICKNESS
from bullet import Bullet
from display import Display

class World:
    def __init__(self, screen):
        self.screen = screen
        self.player = pygame.sprite.GroupSingle()
        self.aliens = pygame.sprite.Group()
        self.display = Display(self.screen)
        self.game_over = False
        self.player_score = 0
        self.game_level = 1
        self._generate_world()

In the class constructor, several attributes and groups are initialized. These include the game screen (self.screen), the player's ship (self.player), the aliens (self.aliens), and a display object (self.display) used to show information like the player's score and game level. The constructor also initializes game over status, player score, and game-level variables. It also calls a method named _generate_world(), to set up the initial game state:

# /* world.py
    def _generate_aliens(self):
        # generate opponents
        alien_cols = (WIDTH // CHARACTER_SIZE) // 2
        alien_rows = 3
        for y in range(alien_rows):
            for x in range(alien_cols):
                my_x = CHARACTER_SIZE * x
                my_y = CHARACTER_SIZE * y
                specific_pos = (my_x, my_y)
                self.aliens.add(Alien(specific_pos, CHARACTER_SIZE, y))
        
    # create and add player to the screen
    def _generate_world(self):
        # create the player's ship
        player_x, player_y = WIDTH // 2, HEIGHT - CHARACTER_SIZE
        center_size = CHARACTER_SIZE // 2
        player_pos = (player_x - center_size, player_y)
        self.player.add(Ship(player_pos, CHARACTER_SIZE))
        self._generate_aliens()

The _generate_aliens() method is responsible for creating and positioning the enemy aliens in the game world. It calculates the number of alien columns and rows based on the game's width and character size and iterates through the grid to create aliens at specific positions.

The _generate_world() method is used to set up the initial game world. It creates the player's ship at the center of the screen and generates the aliens using the _generate_aliens() method.

# /* world.py
    def add_additionals(self):
        # add nav bar
        nav = pygame.Rect(0, HEIGHT, WIDTH, NAV_THICKNESS)
        pygame.draw.rect(self.screen, pygame.Color("gray"), nav)
        # render player's life, score and game level
        self.display.show_life(self.player.sprite.life)
        self.display.show_score(self.player_score)
        self.display.show_level(self.game_level)

The add_additionals() method adds additional elements to the game, including a navigation bar at the bottom of the screen, and renders information about the player's life, score, and game level using the display object:

# /* world.py
    def player_move(self, attack = False):
        keys = pygame.key.get_pressed()
        if keys[pygame.K_a] and not self.game_over or keys[pygame.K_LEFT] and not self.game_over:
            if self.player.sprite.rect.left > 0:
                self.player.sprite.move_left()
        if keys[pygame.K_d] and not self.game_over or keys[pygame.K_RIGHT] and not self.game_over:
            if self.player.sprite.rect.right < WIDTH:
                self.player.sprite.move_right()
        if keys[pygame.K_w] and not self.game_over or keys[pygame.K_UP] and not self.game_over:
            if self.player.sprite.rect.top > 0:
                self.player.sprite.move_up()		
        if keys[pygame.K_s] and not self.game_over or keys[pygame.K_DOWN] and not self.game_over:
            if self.player.sprite.rect.bottom < HEIGHT:
                self.player.sprite.move_bottom()
        # game restart button
        if keys[pygame.K_r]:
            self.game_over = False
            self.player_score = 0
            self.game_level = 1
            for alien in self.aliens.sprites():
                alien.kill()
            self._generate_world()
        if attack and not self.game_over:
            self.player.sprite._shoot()

The player_move() method handles player ship movement and shooting. It checks for key presses to move the player's ship in different directions (left, right, up, down) and also allows the player to shoot bullets when an attack flag is passed. The player can move by pressing "W", "A", "S", "D" or the arrow keys. To shoot, the player can press the SPACE bar. Additionally, it checks for a game restart command (if the player presses the "R" key):

# /* world.py
    def _detect_collisions(self):
        # checks if player bullet hits the enemies (aliens)
        player_attack_collision = pygame.sprite.groupcollide(self.aliens, self.player.sprite.player_bullets, True, True)
        if player_attack_collision:
            self.player_score += 10
        # checks if the aliens' bullet hit the player
        for alien in self.aliens.sprites():	
            alien_attack_collision = pygame.sprite.groupcollide(alien.bullets, self.player, True, False)
            if alien_attack_collision:
                self.player.sprite.life -= 1
                break
        # checks if the aliens hit the player
        alien_to_player_collision = pygame.sprite.groupcollide(self.aliens, self.player, True, False)
        if alien_to_player_collision:
            self.player.sprite.life -= 1

The _detect_collisions() method checks for collisions between player bullets and enemy aliens and between alien bullets and the player's ship. It updates the player's score and decreases the player's life accordingly:

# /* world.py
    def _alien_movement(self):
        move_sideward = False
        move_forward = False
        for alien in self.aliens.sprites():
            if alien.to_direction == "right" and alien.rect.right < WIDTH or alien.to_direction == "left" and alien.rect.left > 0:
                move_sideward = True
                move_forward = False
            else:
                move_sideward = False
                move_forward = True
                alien.to_direction = "left" if alien.to_direction == "right" else "right"
                break
        for alien in self.aliens.sprites():
            if move_sideward and not move_forward:
                if alien.to_direction == "right":
                    alien.move_right()
                if alien.to_direction == "left":
                    alien.move_left()
            if not move_sideward and move_forward:
                    alien.move_bottom()

    def _alien_shoot(self):
        for alien in self.aliens.sprites():
            if (WIDTH - alien.rect.x) // CHARACTER_SIZE == (WIDTH - self.player.sprite.rect.x) // CHARACTER_SIZE:
                alien._shoot()
                break

The _alien_movement() method controls the movement of enemy aliens. It determines whether the aliens should move sideward or forward based on whether they've reached the screen's edges. This method also manages the change in direction for the aliens.

The _alien_shoot() method allows enemy aliens to shoot bullets at the player. It checks the alignment of the player and aliens to determine when the aliens should fire:

# /* world.py
    def _check_game_state(self):
        # check if game over
        if self.player.sprite.life <= 0:
            self.game_over = True
            self.display.game_over_message()
        for alien in self.aliens.sprites():
            if alien.rect.top >= HEIGHT:
                self.game_over = True
                self.display.game_over_message()
                break
        # check if next level
        if len(self.aliens) == 0 and self.player.sprite.life > 0:
            self.game_level += 1
            self._generate_aliens()
            for alien in self.aliens.sprites():
                alien.move_speed += self.game_level - 1

The _check_game_state() method monitors the game's state. It checks if the game should end (game over) when the player's life reaches zero or if any alien reaches the bottom of the screen. If all aliens are defeated, the game progresses to the next level:

# /* world.py
    def update(self):
        # detecting if bullet, alien, and player group is colliding
        self._detect_collisions()
        # allows the aliens to move
        self._alien_movement()
        # allows alien to shoot the player
        self._alien_shoot()
        # bullets rendering
        self.player.sprite.player_bullets.update()
        self.player.sprite.player_bullets.draw(self.screen)
        [alien.bullets.update() for alien in self.aliens.sprites()]
        [alien.bullets.draw(self.screen) for alien in self.aliens.sprites()]
        # player ship rendering
        self.player.update()
        self.player.draw(self.screen)
        # alien rendering
        self.aliens.draw(self.screen)
        # add nav
        self.add_additionals()
        # checks game state
        self._check_game_state()

The update() method is the core game loop. It updates various game elements, such as bullet positions, alien movements, player ship rendering, alien rendering, and additional elements like the navigation bar. It also checks the game state to determine if the game should continue, end, or progress to the next level.

Adding Game Display

Lastly, create another class in display.py and call it the Display class. The Display class manages the rendering of in-game elements, including player lives, scores, game levels, and event messages like "GAME OVER". These elements are essential for conveying important information to the player, and the class provides a structured way to handle their presentation on the game screen:

# /* display.py
import pygame
from settings import WIDTH, HEIGHT, SPACE, FONT_SIZE, EVENT_FONT_SIZE

pygame.font.init()

class Display:
    def __init__(self, screen):
        self.screen = screen
        self.score_font = pygame.font.SysFont("monospace", FONT_SIZE)
        self.level_font = pygame.font.SysFont("impact", FONT_SIZE)
        self.event_font = pygame.font.SysFont("impact", EVENT_FONT_SIZE)
        self.text_color = pygame.Color("blue")
        self.event_color = pygame.Color("red")

    def show_life(self, life):
        life_size = 30
        img_path = "assets/life/life.png"
        life_image = pygame.image.load(img_path)
        life_image = pygame.transform.scale(life_image, (life_size, life_size))
        life_x = SPACE // 2
        if life != 0:
            for life in range(life):
                self.screen.blit(life_image, (life_x, HEIGHT + (SPACE // 2)))
                life_x += life_size

    def show_score(self, score):
        score_x = WIDTH // 3
        score = self.score_font.render(f'score: {score}', True, self.text_color)
        self.screen.blit(score, (score_x, (HEIGHT + (SPACE // 2))))

    def show_level(self, level):
        level_x = WIDTH // 3
        level = self.level_font.render(f'Level {level}', True, self.text_color)
        self.screen.blit(level, (level_x * 2, (HEIGHT + (SPACE // 2))))

    def game_over_message(self):
        message = self.event_font.render('GAME OVER!!', True, self.event_color)
        self.screen.blit(message, ((WIDTH // 3) - (EVENT_FONT_SIZE // 2), (HEIGHT // 2) - (EVENT_FONT_SIZE // 2)))

We start by importing essential modules and constants, such as pygame for game development and various settings like WIDTH, HEIGHT, SPACE, FONT_SIZE, and EVENT_FONT_SIZE. The pygame.font.init() function is called to initialize the pygame font module, enabling the code to work with fonts for rendering text.

The Display class is introduced to manage the rendering of text and visual elements on the game screen. It takes the game screen (screen) as an argument, allowing it to handle display-related tasks. In the constructor, several attributes are initialized. These include self.screen, which stores the game screen, and fonts (self.score_font, self.level_font, and self.event_font) for displaying text. The font styles and sizes are specified, and colors for regular text (self.text_color) and event text (self.event_color) are defined.

The show_life() method is responsible for presenting the player's remaining lives visually. It loads a life image from a file, scales it to a specific size, and then arranges multiple life images in a row at the bottom of the screen, corresponding to the player's remaining lives.

The show_score() method is designed to render and display the player's score on the screen. It creates a text surface using the player's score, applying the score_font and positioning it at a specific location on the screen.

The show_level() method is used to visually represent the current game level. It renders the game level as text using level_font and places it in a designated position on the screen.

The game_over_message() method is responsible for displaying a "GAME OVER" message on the screen when the game ends. It creates a text surface for the message using event_font and positions it at the center of the screen.

And we are now done coding! To try our game, open your terminal, head to the game directory, then run python main.py to call the game.

Here are some game snapshots:

Running the Game

Game while Playing

Different Game Level

Game over

Here's a video of me playing the game:

Conclusion

In conclusion, this tutorial has provided a detailed examination of the Pygame implementation of the classic Space Invaders game. We've delved into the code, dissecting its key components, from player mechanics to enemy behavior and game presentation. With this knowledge, you are well-prepared to modify, expand, and create your versions of Space Invaders or other game projects using Pygame.

You can get the complete code here.

Here are some other game development tutorials:

Happy coding ♥

Ready for more? Dive deeper into coding with our AI-powered Code Explainer. Don't miss it!

View Full Code Explain My Code
Sharing is caring!



Read Also



Comment panel

    Got a coding query or need some guidance before you comment? Check out this Python Code Assistant for expert advice and handy tips. It's like having a coding tutor right in your fingertips!