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.
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:
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().
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:
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:
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!
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:
...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).
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):
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.)
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.
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:
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:
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.
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
andfleet.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), andmain.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 ourGame
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:All right! Let's start with our
__init__()
method.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 thecurses
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 ourlast_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 ofstdscr
andself.window
; we'll see why in a little bit.Next, our
run
method just creates an infinite loop that starts our game a-tickin':As for
tick
, all we do at the moment is delegate to ourupdate
method. (We could imagine including other functionality here as well; even though all we do isupdate
, I like wrapping that behavior intick
, since it creates a common API for all our game components.)As for
update
, it handles... well, updating our game!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 ourself.last_tick
by adding thenew_tick
amount, then calltick
on our fleet and player so they can update, too (passing in theself.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 checkself.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:
We loop over all the lasers and invaders, and if we find a collision (more on this soon), we do three things:
curses
library when we get to our_initialize_colors()
method.8
(this happens to be the color black), we decrement the number ofremaining_invaders
by one (treating the invader as destroyed).8
, we just set it back to8
(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()
, andlost()
; 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 haslost()
when the fleet'sy
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 gameis_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'ssys.exit()
.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
andy
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: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 incurses
using theinit_pair()
function here: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 usingcurses
. 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), adraw()
method (to draw the object on the screen), and atick()
method (to determine how our game objects should change and behave with each game step). (Oftentimes, ourtick()
method just delegates to anupdate()
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:Okay! As usual, let's start by looking at our
__init__()
method:As mentioned, we start off by saving references to our screen and window objects via
self.stdscr = stdscr
andself.window = window
. We also set awidth
(to help detect how far across the screen our invader extends) andspeed
(to control how quickly it moves), as well as adirection
(+1 for left-to-right and -1 for right-to-left). We also set aself.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 asx
andy
coordinates.(Note that we pass aposition
to our constructor to tell the invader where to draw itself on the screen; theposition
is an(x, y
) tuple.)We set our
block_color
to1
andempty_color
to8
(red and black, respectively), set ourlast_tick
to the current time, and ourmove_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!In order to
draw()
our invader, we iterate over the characters in ourself.__repr__()
two-dimensional list, drawing a red block when we see a'O'
and an empty/black block when we see' '
:Our
_draw_block()
method takes anx
(column position),y
(row position), andcolor
and adds the block to the screen usingcurses
'stdscr.addstr()
method (which you can read more about here):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 itsx
andy
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 ourx
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 ourx
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 ourself.last_tick
in preparation for the next game loop.In order to
update()
our screen, we just need to move and redraw:As mentioned, our
tick()
method just wrapsupdate()
for now:...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 aFleet
.fleet.py
Our
Fleet
class is pretty simple! We'll walk through its three methods (__init__()
,tick()
, andy()
), but here's the whole thing:As usual, our
__init__()
method starts by saving references tostdscr
andwindow
, as well as setting awidth
andrange
(these are actually identical to what we did in ourInvader
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 (x
s of 5, 20, 35, and 50) and all at the samey
(2). (Again, if you fork this code, it might be a good idea to set thesex
values based on the number of invaders we have, rather than hard-code them.) We also set astep
of5
(we'll use this to determine how far to "drop down" after our fleet has moved all the way across the screen), alast_tick
of the current time, amove_threshold
of 1 (similar to what we did to control the rate of movement for our invaders), anumber_of_invaders
equal to the length of ourself.invaders
list, and finally,remaining_invaders
equal tonumber_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 calltick()
on all the invaders inself.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 ourlast_tick
. (The first branch in ourif
statement handles left-to-right movement causes us to drop down and reverse direction instead of falling off the right side of the screen; theelif
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 currenty
value (row position) of our invading fleet. (All our invaders have the samey
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.On to the
Player
class!player.py
You know the drill by now! Here's the whole
Player
class: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, andx
andy
coordinates. And just like aFleet
has a list of invaders, aPlayer
has a list of lasers to fire! (We'll see how lasers work soon.)Our
draw()
method is pretty straightforward: we erase our old position withself.stdscr.erase()
, then draw a new player (which is just a green rectangle) at the specifiedx
andy
coordinates.Our
tick
method does a few things (and we could delegate some of them to anupdate()
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 ininstruction
. 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 settingx
to its min value (the left side of the screen) if we're about to go below that, and we setx
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:
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()
, anddraw()
methods. There's nothing you haven't seen before in__init__()
ordraw()
, so we'll focus ontick()
, 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'sy
value (moving it one row up on the screen) for each tick of the game. Since a laser gets its initialx
andy
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 ingame.py
:We have only a single function here,
main
, that we call at the bottom of our file (using thewrapper
object fromcurses
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 whatmain
does:curses.curs_set(False)
, which makes the cursor invisible.stdscr.nodelay(True)
, which makes ourstdscr.getch()
call non-blocking (that is, our game will keep ticking while it waits for user input).stdscr.clear()
.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.
Nice.
Have an upvote.