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:

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.)

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).

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

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.

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).

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.

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:

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:

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!

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:

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:

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).

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.)

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):

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.

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.)

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.

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:

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!

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:

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:

It sets curses.curs_set(False), which makes the cursor invisible.

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).

It clears the screen in preparation for a new game using stdscr.clear().

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.

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.