Learn to Code via Tutorials on Repl.it

← Back to all posts
Game Tutorial: Space Invaders
ericqweinstein (203)

Hi everyone,

I put together a little Space Invaders game and thought I'd write a tutorial on how it works. (You'll want to run it in the REPL Run environment, since Space Invaders requires a fair amount of screen real estate to play.) Feel free to fork the REPL and add to it!

The game is broken up into six main files: game.py (which handles the game logic), invader.py and fleet.py (which handle drawing individual invaders and a whole fleet of invaders, respectively), player.py (which manages moving the player on the screen), laser.py (so the player can fire at the invading fleet), and main.py (which ties everything together and creates a new game). Let's look at each one in turn.

game.py

The game.py file houses our Game class, which manages the behavior and data needed to run a Space Invaders game. We'll go through each method one at a time, but here's the file in its entirety if you're curious:

import curses
import datetime as dt import sys

from datetime import datetime
from fleet import Fleet
from player import Player


class Game(object):

    def __init__(self, stdscr):
        self.stdscr = stdscr
        self._initialize_colors()
        self.last_tick = datetime.now()
        self.window = self.stdscr.getmaxyx()
        self.fleet = Fleet(stdscr, self.window)
        self.player = Player(stdscr, self.window)

    def run(self):
        while True:
            self.tick()

    def tick(self):
        self.update()

    def update(self):
        new_tick = dt.timedelta(milliseconds=10)
        self.last_tick += new_tick
        self.fleet.tick(self.last_tick)
        self.player.tick(self.last_tick)
        self.detect_collisions()

        if self.is_over():
            if self.won():
                self.end('You won!')
            else:
                self.end('Oh no, you lost!')

    def detect_collisions(self):
        for laser in self.player.lasers:
            for invader in self.fleet.invaders:
                if self._collision_found(laser, invader):
                    invader.block_color += 1

                    if invader.block_color == 7:
                        self.fleet.remaining_invaders -= 1

                    if invader.block_color > 8:
                        invader.block_color = 8

    def won(self):
        return self.fleet.remaining_invaders == 0

    def lost(self):
        return self.fleet.y() >= self.player.y

    def is_over(self):
        return self.won() or self.lost()

    def end(self, message):
        sys.stdout.write(message)
        sys.exit(0)

    def _collision_found(self, laser, invader):
        # Left
        if laser.x + laser.width < invader.x:
            return False
        # Right
        elif invader.x + invader.width < laser.x:
            return False
        # Above
        elif laser.y + 1 < invader.y:
            return False
        # Below
        elif invader.y + 8 < laser.y:
            return False

        return True

    def _initialize_colors(self):
        curses.start_color()
        curses.init_pair(1, curses.COLOR_RED,     curses.COLOR_RED)
        curses.init_pair(2, curses.COLOR_BLUE,    curses.COLOR_BLUE)
        curses.init_pair(3, curses.COLOR_GREEN,   curses.COLOR_GREEN)
        curses.init_pair(4, curses.COLOR_MAGENTA, curses.COLOR_MAGENTA)
        curses.init_pair(5, curses.COLOR_CYAN,    curses.COLOR_CYAN)
        curses.init_pair(6, curses.COLOR_YELLOW,  curses.COLOR_YELLOW)
        curses.init_pair(7, curses.COLOR_WHITE,   curses.COLOR_WHITE)
        curses.init_pair(8, curses.COLOR_BLACK,   curses.COLOR_BLACK)
        curses.init_pair(10, 10, 10)

All right! Let's start with our __init__() method.

def __init__(self, stdscr):
    self.stdscr = stdscr
    self._initialize_colors()
    self.last_tick = datetime.now()
    self.window = self.stdscr.getmaxyx()
    self.fleet = Fleet(stdscr, self.window)
    self.player = Player(stdscr, self.window)

As you can see, when we initialize a new game, we save a reference to stdscr (a window object representing the entire screen). This is part of the curses Python library, which you can read more about here. We also call _initialize_colors to set up our terminal colors (more on this soon), initialize our last_tick to the current time, and save references to our window dimensions (self.window = self.stdscr.getmaxyx()), fleet of invaders (self.fleet = Fleet(stdscr, self.window)), and the human player (self.player = Player(stdscr, self.window)). Note that our fleet of invaders and player each get passed references to the overall screen in the form of stdscr and self.window; we'll see why in a little bit.

Next, our run method just creates an infinite loop that starts our game a-tickin':

def run(self):
    while True:
        self.tick()

As for tick, all we do at the moment is delegate to our update method. (We could imagine including other functionality here as well; even though all we do is update, I like wrapping that behavior in tick, since it creates a common API for all our game components.)

def tick(self):
    self.update()

As for update, it handles... well, updating our game!

def update(self):
    new_tick = dt.timedelta(milliseconds=10)
    self.last_tick += new_tick
    self.fleet.tick(self.last_tick)
    self.player.tick(self.last_tick)
    self.detect_collisions()

    if self.is_over():
        if self.won():
            self.end('You won!')
        else:
            self.end('Oh no, you lost!')

First, we create a new_tick equal to ten milliseconds (this is how long we wait between updates—that is, the amount of time that passes between each refresh of the game screen). We update our self.last_tick by adding the new_tick amount, then call tick on our fleet and player so they can update, too (passing in the self.last_tick in order to keep our game clock synchronized). We check to see if there are any collisions (that is, if any of the lasers fired by the player have hit any of the invaders), and finally check self.is_over() to see if our game has ended, providing appropriate messages depending on whether the player has won or lost (more on this soon).

Let's see how we detect collisions between lasers and invaders:

def detect_collisions(self):
    for laser in self.player.lasers:
        for invader in self.fleet.invaders:
            if self._collision_found(laser, invader):
                invader.block_color += 1

                if invader.block_color == 8:
                    self.fleet.remaining_invaders -= 1

                if invader.block_color > 8:
                    invader.block_color = 8

We loop over all the lasers and invaders, and if we find a collision (more on this soon), we do three things:

  1. We increment the invader's color (this has the effect of making the invader flicker when hit, since it will cycle through all the colors from red to black); we'll see more about how colors work with the curses library when we get to our _initialize_colors() method.
  2. If the invader's color is 8 (this happens to be the color black), we decrement the number of remaining_invaders by one (treating the invader as destroyed).
  3. If the invader's color ever exceeds 8, we just set it back to 8 (to ensure the blocks that make up the invader stay black, matching the game background).

The three methods we use to check whether the game has ended are is_over(), won(), and lost(); each is pretty short, so let's look at them all at once.

def won(self):
    return self.fleet.remaining_invaders == 0

def lost(self):
    return self.fleet.y() >= self.player.y

def is_over(self):
    return self.won() or self.lost()

To check if a player has won(), we just check whether there are no remaining invaders. A player has lost() when the fleet's y value (its height above the bottom of the screen) is greater than or equal to the player's (meaning the fleet has landed/invaded, since it's gotten down to where the player is on the screen). The game is_over() when the player either wins or loses.

We end() the game like so, by writing an appropriate message (like "You won!" or "Oh no, you lost!") and exiting the program using Python's sys.exit().

def end(self, message):
    sys.stdout.write(message)
    sys.exit(0)

Okay! Let's get back to collision detection. We know there's a collision if any of part of a laser overlaps with any part of an invader. This can be a little tricky to compute, since we have to take the x and y coordinates of each block into account, as well as those blocks' heights and widths. One way to do it is to say that there's no collision if we shoot wide (too far left or right), high, or low, and that otherwise, we must have a collision. So! That's what we do in _collision_found(): we check to see if we've missed by going too far left, right, high, or low, and if we haven't missed in those directions, we must have made a hit:

def _collision_found(self, laser, invader):
    # Too far left
    if laser.x + laser.width < invader.x:
        return False
    # Too far right
    elif invader.x + invader.width < laser.x:
        return False
    # Too high
    elif laser.y + 1 < invader.y: # The laser is one block wide
        return False
    # Too low
    elif invader.y + 8 < laser.y: # The invader is eight blocks high
        return False

    return True

Finally, we finish up our Game class with a little utility method that sets all the colors we're going to use (you can read more about setting colors in curses using the init_pair() function here:

def _initialize_colors(self):
    curses.start_color()
    curses.init_pair(1, curses.COLOR_RED,     curses.COLOR_RED)
    curses.init_pair(2, curses.COLOR_BLUE,    curses.COLOR_BLUE)
    curses.init_pair(3, curses.COLOR_GREEN,   curses.COLOR_GREEN)
    curses.init_pair(4, curses.COLOR_MAGENTA, curses.COLOR_MAGENTA)
    curses.init_pair(5, curses.COLOR_CYAN,    curses.COLOR_CYAN)
    curses.init_pair(6, curses.COLOR_YELLOW,  curses.COLOR_YELLOW)
    curses.init_pair(7, curses.COLOR_WHITE,   curses.COLOR_WHITE)
    curses.init_pair(8, curses.COLOR_BLACK,   curses.COLOR_BLACK)
    curses.init_pair(10, 10, 10)

invader.py

All right! Let's move on to our Invader class, where we'll start to see how to draw objects on the screen using curses. We'll also start to see a common API emerge among our game components: most of them have an __init__() method (to set up the object with attributes like location, color, and direction), a draw() method (to draw the object on the screen), and a tick() method (to determine how our game objects should change and behave with each game step). (Oftentimes, our tick() method just delegates to an update() method, but as mentioned, we wrap that for now in case we want to add extra functionality later.) Again, we'll go through each method one-by-one, but here's the whole class if you just want to dive in:

import curses

from datetime import datetime


class Invader(object):

    def __init__(self, stdscr, window, position):
        self.stdscr = stdscr
        self.window = window
        self.width = 11
        self.speed = 5
        self.direction = 1
        self.range = (0, self.window[1] - self.width - 1)
        self.x = position[0]
        self.y = position[1]
        self.block_color = 1
        self.empty_color = 8
        self.block_width = 1
        self.last_tick = datetime.now()
        self.move_threshold = 0.5


    def __repr__(self):
        return [
            [' ', ' ', 'O', ' ', ' ', ' ', ' ', ' ', 'O', ' ', ' '],
            [' ', ' ', ' ', 'O', ' ', ' ', ' ', 'O', ' ', ' ', ' '],
            [' ', ' ', 'O', 'O', 'O', 'O', 'O', 'O', 'O', ' ', ' '],
            [' ', 'O', 'O', ' ', 'O', 'O', 'O', ' ', 'O', 'O', ' '],
            ['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O'],
            ['O', ' ', 'O', 'O', 'O', 'O', 'O', 'O', 'O', ' ', 'O'],
            ['O', ' ', 'O', ' ', ' ', ' ', ' ', ' ', 'O', ' ', 'O'],
            [' ', ' ', ' ', 'O', 'O', ' ', 'O', 'O', ' ', ' ', ' ']
        ]

    def draw(self):
        for y, row in enumerate(self.__repr__()):
            for x, char in enumerate(row):
                if char == ' ':
                    self._draw_block(x, y, self.empty_color)
                else:
                    self._draw_block(x, y, self.block_color)

    def _draw_block(self, x, y, color):
        self.stdscr.addstr(
            self.y + y,
            self.x + x,
            ' ' * self.block_width,
            curses.color_pair(color)
        )

    def _move(self, tick_number):
        # This is a kind of "brake" to ensure that the invaders don't move for every single game tick
        # (since we want to animate the player's motion quickly, but the invaders should move more slowly).
        if datetime.now().timestamp() - self.last_tick.timestamp() > self.move_threshold:
            x = self.x + 1
            x = min(x, max(self.range))
            x = max(x, min(self.range))
            x = x - self.x
            self.x += x * self.speed * self.direction
            self.last_tick = datetime.now()

    def update(self, tick_number):
        self._move(tick_number)
        self.draw()

    def tick(self, tick_number):
        self.update(tick_number)

Okay! As usual, let's start by looking at our __init__() method:

def __init__(self, stdscr, window, position):
    self.stdscr = stdscr
    self.window = window
    self.width = 11
    self.speed = 5
    self.direction = 1
    self.range = (0, self.window[1] - self.width - 1)
    self.x = position[0]
    self.y = position[1]
    self.block_color = 1
    self.empty_color = 8
    self.block_width = 1
    self.last_tick = datetime.now()
    self.move_threshold = 0.5

As mentioned, we start off by saving references to our screen and window objects via self.stdscr = stdscr and self.window = window. We also set a width (to help detect how far across the screen our invader extends) and speed (to control how quickly it moves), as well as a direction (+1 for left-to-right and -1 for right-to-left). We also set a self.range (equal to the max width minus one block and the width of our invader) that ensures our invaders don't try to wander off the screen, as well as x and y coordinates.(Note that we pass a position to our constructor to tell the invader where to draw itself on the screen; the position is an (x, y) tuple.)

We set our block_color to 1 and empty_color to 8 (red and black, respectively), set our last_tick to the current time, and our move_threshold to 0.5 (this will help us slow our invaders down, ensuring they only move once every half-second).

Next up is our __repr__() function! __repr__() is a built-in Python function that you can override to control the printed representation of your object. We return a two-dimensional list of characters, using 'O' to represent a red block and ' ' to represent a black (empty) block. If you look closely, you can see it looks like our on-screen invader!

def __repr__(self):
    return [
        [' ', ' ', 'O', ' ', ' ', ' ', ' ', ' ', 'O', ' ', ' '],
        [' ', ' ', ' ', 'O', ' ', ' ', ' ', 'O', ' ', ' ', ' '],
        [' ', ' ', 'O', 'O', 'O', 'O', 'O', 'O', 'O', ' ', ' '],
        [' ', 'O', 'O', ' ', 'O', 'O', 'O', ' ', 'O', 'O', ' '],
        ['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'O'],
        ['O', ' ', 'O', 'O', 'O', 'O', 'O', 'O', 'O', ' ', 'O'],
        ['O', ' ', 'O', ' ', ' ', ' ', ' ', ' ', 'O', ' ', 'O'],
        [' ', ' ', ' ', 'O', 'O', ' ', 'O', 'O', ' ', ' ', ' ']
    ]

In order to draw() our invader, we iterate over the characters in our self.__repr__() two-dimensional list, drawing a red block when we see a 'O' and an empty/black block when we see ' ':

def draw(self):
    for y, row in enumerate(self.__repr__()):
        for x, char in enumerate(row):
            if char == ' ':
                self._draw_block(x, y, self.empty_color)
            else:
                self._draw_block(x, y, self.block_color)

Our _draw_block() method takes an x (column position), y (row position), and color and adds the block to the screen using curses' stdscr.addstr() method (which you can read more about here):

def _draw_block(self, x, y, color):
    self.stdscr.addstr(
        self.y + y,
        self.x + x,
        ' ' * self.block_width,
        curses.color_pair(color)
    )

Now that we know what we need in order to draw our invader, let's take a look at how we get it to move. Every game tick, we want to make a decision about our invader's position (using its x and y coordinates) so we can redraw it in its new position:

def _move(self, tick_number):
    # This is a kind of "brake" to ensure that the invaders don't move for every single game tick
    # (since we want to animate the player's motion quickly, but the invaders should move more slowly).
    if datetime.now().timestamp() - self.last_tick.timestamp() > self.move_threshold:
        x = self.x + 1
        x = min(x, max(self.range))
        x = max(x, min(self.range))
        x = x - self.x
        self.x += x * self.speed * self.direction
        self.last_tick = datetime.now()

The first line of code in this method is a little confusing, but what we're doing is looking at the difference between the current time and our prior tick. If enough time has passed, update our x value by one (moving a little to the right), adjusting our x to the screen range minimum (in case we're about to fall off the left side of the screen) or the screen range maximum (in case we're about to fall off the right side of the screen). We update our x position by multiplying by our speed (how many columns we move per tick) and direction (+1 to go right-to-left, -1 to go left-to-right). Finally, we update our self.last_tick in preparation for the next game loop.

In order to update() our screen, we just need to move and redraw:

def update(self, tick_number):
    self._move(tick_number)
    self.draw()

As mentioned, our tick() method just wraps update() for now:

def tick(self, tick_number):
    self.update(tick_number)

...and that's all we need to set up our Invader class! Now let's look at what we need to do to organize our invaders into a Fleet.

fleet.py

Our Fleet class is pretty simple! We'll walk through its three methods (__init__(), tick(), and y()), but here's the whole thing:

from datetime import datetime

from invader import Invader


class Fleet(object):

    def __init__(self, stdscr, window):
        self.stdscr = stdscr
        # This is actually the width of an invader
        self.width = 11
        self.window = window
        self.range = (0, self.window[1] - self.width - 1)
        self.invaders = [
            Invader(stdscr, window, (5, 2)),
            Invader(stdscr, window, (20, 2)),
            Invader(stdscr, window, (35, 2)),
            Invader(stdscr, window, (50, 2)),
        ]
        self.step = 5
        self.last_tick = datetime.now()
        self.move_threshold = 1
        self.number_of_invaders = len(self.invaders)
        self.remaining_invaders = self.number_of_invaders

    def tick(self, tick_number):
        [invader.tick(tick_number) for invader in self.invaders]

        if self.invaders[self.number_of_invaders - 1].x + self.width // 2 >= max(self.range):
            # This is the "brake" for things that should animate more slowly than the main game loop.
            if datetime.now().timestamp() - self.last_tick.timestamp() > self.move_threshold:
                self.stdscr.clear()
                for invader in self.invaders:
                    invader.direction = -1
                    invader.y += self.step
                    self.last_tick = datetime.now()
        elif self.invaders[0].x <= min(self.range):
            if datetime.now().timestamp() - self.last_tick.timestamp() > self.move_threshold:
                self.stdscr.clear()
                for invader in self.invaders:
                    invader.direction = 1
                    invader.y += self.step
                    self.last_tick = datetime.now()

    def y(self):
        return self.invaders[0].y + 8

As usual, our __init__() method starts by saving references to stdscr and window, as well as setting a width and range (these are actually identical to what we did in our Invader class, since we only need a single invader's width in order to determine whether we're about to crash into a wall). If you fork this REPL to add new functionality, fix bugs, or refactor the code, it might be a good idea to use the invader's width and range (rather than duplicating that code here)!

Next, we create a list of self.invaders. In our case, we set up four invaders that are 15 blocks apart (xs of 5, 20, 35, and 50) and all at the same y (2). (Again, if you fork this code, it might be a good idea to set these x values based on the number of invaders we have, rather than hard-code them.) We also set a step of 5 (we'll use this to determine how far to "drop down" after our fleet has moved all the way across the screen), a last_tick of the current time, a move_threshold of 1 (similar to what we did to control the rate of movement for our invaders), a number_of_invaders equal to the length of our self.invaders list, and finally, remaining_invaders equal to number_of_invaders (we'll decrement this value as invaders are destroyed by the player).

def __init__(self, stdscr, window):
    self.stdscr = stdscr
    # This is actually the width of an invader
    self.width = 11
    self.window = window
    self.range = (0, self.window[1] - self.width - 1)
    self.invaders = [
        Invader(stdscr, window, (5, 2)),
        Invader(stdscr, window, (20, 2)),
        Invader(stdscr, window, (35, 2)),
        Invader(stdscr, window, (50, 2)),
    ]
    self.step = 5
    self.last_tick = datetime.now()
    self.move_threshold = 1
    self.number_of_invaders = len(self.invaders)
    self.remaining_invaders = self.number_of_invaders

Next, our tick() method controls the movement of our overall fleet. Since invaders have a tick method and can control their won left-to-right movement, we simply call tick() on all the invaders in self.invaders to move them left-to-right. We use the same "brake" we used for our invaders to prevent them from updating too quickly, and if our invading fleet is about to drive off the screen, we reverse direction, drop down, and update our last_tick. (The first branch in our if statement handles left-to-right movement causes us to drop down and reverse direction instead of falling off the right side of the screen; the elif handles right-to-left movement and preventing us from falling off the left side of the screen.)

def tick(self, tick_number):
    [invader.tick(tick_number) for invader in self.invaders]

    if self.invaders[self.number_of_invaders - 1].x + self.width // 2 >= max(self.range):
        # This is the "brake" for things that should animate more slowly than the main game loop.
        if datetime.now().timestamp() - self.last_tick.timestamp() > self.move_threshold:
            self.stdscr.clear()
            for invader in self.invaders:
                invader.direction = -1
                invader.y += self.step
                self.last_tick = datetime.now()
    elif self.invaders[0].x <= min(self.range):
        if datetime.now().timestamp() - self.last_tick.timestamp() > self.move_threshold:
            self.stdscr.clear()
            for invader in self.invaders:
                invader.direction = 1
                invader.y += self.step
                self.last_tick = datetime.now()

Finally, we create a helper function called y() that just gets the current y value (row position) of our invading fleet. (All our invaders have the same y value, so we arbitrarily take the first one in our fleet, since a fleet should include at least one invader):

def y(self):
    return self.invaders[0].y + 8 # Invaders are 8 blocks tall.

Again, if you fork this REPL, it might be a good idea to set an invader.height = 8 so we don't have to sprinkle this "magic number" throughout our code in order to take the invaders' heights into account.

On to the Player class!

player.py

You know the drill by now! Here's the whole Player class:

import curses

from datetime import datetime
from laser import Laser


class Player(object):

    def __init__(self, stdscr, window):
        self.stdscr = stdscr
        self.width = 6
        self.window = window
        self.range = (0, self.window[1] - self.width)
        self.speed = 1
        self.color = 3
        self.x = self.window[1] // 2
        self.y = self.window[0] - 5
        self.lasers = []

    def draw(self):
        self.stdscr.erase()
        self.stdscr.addstr(
            self.y,
            self.x,
            ' ' * self.width,
            curses.color_pair(self.color)
        )

    def tick(self, tick_number):
        [laser.tick(tick_number) for laser in self.lasers]
        self._handle_user_input()
        self.draw()

    def _handle_user_input(self):
        instruction = self.stdscr.getch()

        if instruction == curses.KEY_LEFT:
            x = self.x - 1
        elif instruction == curses.KEY_RIGHT:
            x = self.x + 1
        else:
            x = self.x

        if instruction == ord(' '):
            self.lasers.append(Laser(self.stdscr, self.x, self.y))

        # Ensure we don't drive off the board
        x = min(x, max(self.range))
        x = max(x, min(self.range))
        x = x - self.x

        self.x += x

In our __init__() method, we do a lot of familiar things: save references to our screen (stdscr) and window (window), set a width (self.width = 6), a window range (so we don't fly off the screen), a speed, a color, and x and y coordinates. And just like a Fleet has a list of invaders, a Player has a list of lasers to fire! (We'll see how lasers work soon.)

def __init__(self, stdscr, window):
    self.stdscr = stdscr
    self.width = 6
    self.window = window
    self.range = (0, self.window[1] - self.width)
    self.speed = 1
    self.color = 3
    self.x = self.window[1] // 2
    self.y = self.window[0] - 5
    self.lasers = []

Our draw() method is pretty straightforward: we erase our old position with self.stdscr.erase(), then draw a new player (which is just a green rectangle) at the specified x and y coordinates.

def draw(self):
    self.stdscr.erase()
    self.stdscr.addstr(
        self.y,
        self.x,
        ' ' * self.width,
        curses.color_pair(self.color)
    )

Our tick method does a few things (and we could delegate some of them to an update() method if we wanted!): we update each laser in our array of lasers, respond to user input (which we'll cover in just a minute), and redraw the screen to reflect our changes in the terminal.

def tick(self, tick_number):
    [laser.tick(tick_number) for laser in self.lasers]
    self._handle_user_input()
    self.draw()

Unlike our other game object, the Player has to respond to human input (and the game loop has to be pretty fast in order for the animation to be fast—that's why we set the overall game loop to 10 milliseconds earlier, but we use our "brake" to ensure invaders move more slowly). To accomplish that, we have our _handle_user_input() method:

def _handle_user_input(self):
    instruction = self.stdscr.getch()

    if instruction == curses.KEY_LEFT:
        x = self.x - 1
    elif instruction == curses.KEY_RIGHT:
        x = self.x + 1
    else:
        x = self.x

    if instruction == ord(' '):
        self.lasers.append(Laser(self.stdscr, self.x, self.y))

    # Ensure we don't drive off the board
    x = min(x, max(self.range))
    x = max(x, min(self.range))
    x = x - self.x

    self.x += x

Here, we use curses' stdscr.getch() method to determine what key the player is pressing, storing that in instruction. If it's the left arrow key (if instruction == curses.KEY_LEFT), we move left; if it's the right arrow key (if instruction == curses.KEY_RIGHT), we move right. We ensure we don't drive off the board by setting x to its min value (the left side of the screen) if we're about to go below that, and we set x to its max value (the right edge of the screen) any time we're about to go above that and drive off the right side of the screen. If the user presses the space bar (if instruction == ord(' ')), we fire a laser!

Next up: the Laser class!

laser.py

This is a short one—here's the file in its entirety:

import curses


class Laser(object):

    def __init__(self, stdscr, x, y):
        self.stdscr = stdscr
        self.x = x
        self.y = y
        self.color = 7
        self.width = 1

    def tick(self, tick_number):
        if (self.y <= 0):
            self.color = 8
        else:
            self.y -= 1

        self.draw()

    def draw(self):
        self.stdscr.addstr(
            self.y,
            self.x,
            ' ' * self.width,
            curses.color_pair(self.color)
        )

There's not a ton going on here, so while we'll still go through each method, we won't look at each code snippet individually.

As usual, we have __init__(), tick(), and draw() methods. There's nothing you haven't seen before in __init__() or draw(), so we'll focus on tick(), which does two things: it changes our laser color from white to black when it reaches the top of the screen (to simulate our lasers going off the top of the terminal window), and it decrements the laser's y value (moving it one row up on the screen) for each tick of the game. Since a laser gets its initial x and y values from the player, the end result is a little white laser bolt flying across the screen from the player toward the invading fleet!

main.py

Now that we have all the pieces of our game in place, we can tie everything together neatly by creating a new Game instance in game.py:

import curses

from curses import wrapper
from game import Game


def main(stdscr):
    curses.curs_set(False)

    stdscr.nodelay(True)
    stdscr.clear()

    Game(stdscr).run()


if __name__ == '__main__':
    wrapper(main)

We have only a single function here, main, that we call at the bottom of our file (using the wrapper object from curses in order to automate all the setup and teardown work needed to neatly move from the regular REPL into the game terminal; you can read more about it here). Here's what main does:

  1. It sets curses.curs_set(False), which makes the cursor invisible.
  2. It sets stdscr.nodelay(True), which makes our stdscr.getch() call non-blocking (that is, our game will keep ticking while it waits for user input).
  3. It clears the screen in preparation for a new game using stdscr.clear().
  4. Finally, we create and run a new game via Game(stdscr).run().

...and that's it!

There are lots of opportunities to make this game better: making it so multiple hits are required to kill an invader, adding multiple rows of invaders, adding scorekeeping functionality, making the invaders move faster over time, and so on. The sky's the limit!

I hope you enjoyed this tutorial, and feel free to fork this REPL to add more functionality.

Commentshotnewtop
a5rocks (535)

Nice!

Just a couple comments. One, you can customize colors in curses using curses.init_color(), because the repl.it allows custom colors (curses.can_change_colors() returns true). And... I forgot the other.

I plan to make a "terminal arcade", where you can play games in the terminal. I think this tutorial will definitely help me :) now I just need to become motivated.

[deleted]

nice job! I love all your tutorials