Game Tutorial: Tetris
Hi everyone,
I put together a little Tetris game and thought I'd write a tutorial on how it works. (This code is based on work by Alex Wilson.) Feel free to fork the REPL and add to it!
The game is broken up into four main files: board.py
(which manages the state of the Tetris "board"), game.py
(which handles the game logic), pieces.py
(which describes the different Tetris tetrominoes), and main.py
(which ties everything together and creates a new game). Let's look at each one in turn.
board.py
This is the longest and most complex part of the game, so we'll go through it step-by-step. We'll also be using the curses library for handling some of our terminal interactions, so if you're not familiar with it, feel free to peruse the documentation before diving in.
Okay! We'll start with our Board
class. The __init__
method sets up the state of our game, including the number of rows and columns in our board, the current "fill pattern" of pieces on the board (self.array
), the currently falling shape, the next shape, the score, the level, and some bookkeeping to manage the way we draw the board and how we handle scoring. It looks like this:
def __init__(self, columns=None, rows=None, level=None):
self.num_rows = rows
self.num_columns = columns
self.array = [[None for _ in range(self.num_columns)] for _ in range(self.num_rows)]
self.falling_shape = None
self.next_shape = None
self.score = 0
self.level = level or 1
self.preview_column = 12
self.preview_row = 1
self.starting_column = 4
self.starting_row = 0
self.drawer = BoardDrawer(self)
self.points_per_line = 20
self.points_per_level = 200
Next, our start_game
game method initializes the score to zero and the level to one, then selects the first shape to fall. The end_game
raises a custom GameOverError
that we throw if we're unable to place a shape for some reason (which the game will catch and use to end itself; I'm not a huge fan of using exceptions for control flow, though, so feel free to refactor this if you fork the REPL!). The new_shape
method handles setting the falling_shape
to the prior next_shape
and getting a new next_shape
. The remove_completed_lines
method handles removing lines that go all the way across the board (increasing the player's score appropriately). Finally, our last few methods handle shape management: settle_falling_shape
and _settle_shape
handle adding the falling shape to the array of shapes that have already fallen, move_shape_left
, move_shape_right
, and rotate_shape
do exactly what you'd expect, let_shape_fall
handles the falling shape as the game "ticks" forward (this is what we call each iteration through the game loop), drop_shape
makes the shape fall immediately when the user hits the "Enter" key, and finally, shape_cannot_be_placed
determines whether the shape can be successfully placed on the board.
Because there's so much game logic here, we've included a separate BoardDrawer
class (to manage drawing the board on the terminal, as opposed to managing the state of the board, which is the Board
class' job). You can see this almost immediately in the __init__
function: while Board
doesn't know about curses
, the library we use to draw in the terminal, the BoardDrawer
class uses it extensively. The first several lines of our __init__
function are just configuring curses
; if you're interested in the details, feel free to check out the documentation! After that (starting on line 183), we pass some state from our Board
to our BoardDrawer
so it knows things like where on the board to draw shapes (using rows and columns) and what size things like blocks and borders should be.
After that, we have several methods we use to update our board in the terminal (they all begin with update_
), as well as a couple of helper methods for clearing the player's score and refreshing the screen. Let's walk through each of these in turn.
The update_falling_piece
method redraws the blocks in the currently falling shape in order to re-draw it one row lower on the board. The update_settled_pieces
does the same thing, but for the already-settled pieces on the board. Our update_shadow
method handles updating the position of the "shadow" on our game board (where the currently falling piece will ultimately land). The update_next_piece
method does the same thing as update_falling_piece
, but for the "preview" piece (the one that will start falling next). update_score_and_level
and clear_score
do what you'd expect, and update_border
handles drawing the screen borders, and the update
method calls all of our update_*
methods in order to update the overall board (it also calls refresh_screen
, which ensures our changes are reflected on the board). Finally, we include a custom GameOverError
exception class so we can throw GameOverErrors
to handle ending the game.
game.py
Our Game
class, by comparison, is pretty straightforward: here's where we put all the logic that runs the game itself. The important methods here are __init__
, pause
, run
, end,
exit
, start_ticking
, stop_ticking
, update
, and process_user_input
. (Note that the ticking methods use _tick
, which, as mentioned, is an iteration in the game loop: that is, a "step" in the game's progression.)
The __init__
method keeps references to our Board
and BoardDrawer
, keeps tracks of "ticks" through the game, and draws the board by calling self.board.start_game()
. The pause
method stops the game loop from iterating (continuing to "tick") in order to pause the game, and the run
method handles the main game loop by taking user input and updating the board in response, only stopping on a GameOverError
(which calls our end
method, exit
ing the program). The start_ticking
, stop_ticking
, and _tick
methods are used to control the running and pausing of the game, and update
(different from the BoardDrawer
's update
method!) keeps track of all our ticking (that is, the state of the game itself). Finally, we use the process_user_input
method to change the game state based on the keys the user presses: right and left to move the falling shape right or left, up to rotate the shape, down to make the shape fall faster, and "Enter" to make the shape drop into place immediately. We can also pause the game by hitting p
or end it by hitting q
.
pieces.py
The pieces.py
file is long, but it's actually not very complex: we just have a Block
class, a Shape
class (a Shape
is made up of four Block
s), and then classes that inherit from Shape
in order to form the seven shapes that occur in a game of Tetris (square, line, "T"-shape, "J"-shape, "L"-shape, "S"-shape, and "Z"-shape).
The Block
class is pretty simple! Here's the whole thing:
class Block(object):
'''Represents one block in a tetris piece.'''
def __init__(self, row_position, column_position, color):
self.row_position = row_position
self.column_position = column_position
self.color = color
We can see that each block just has a position (column and row) as well as a color. The Shape
class is where things get interesting, so we'll go through its methods one at a time.
First, the __init__
method sets the shape's position and color, as well as its orientation (since shapes can rotate) and its constituent blocks. Next, the __eq__
method is a bit of Python magic that allows us to hook into ==
, meaning that we can now check if one Shape
is equal to another using something like shape_1 == shape_2
. We have a couple of convenience methods, _get_random_orientation
and _get_random_color
, which we use to set the orientation and color of new shapes (the ones that are "up next" in the preview window of the terminal). Our _rotate
method uses _rotate_blocks
internally to rotate the blocks that comprise our shape, and our rotate_clockwise
and rotate_counterclockwise
methods use _rotate
in order to rotate our shapes clockwise and counter-clockwise (respectively) during gameplay.
Next, we have four methods, lower_shape_by_one_row
, raise_shape_by_one_row
, shift_shape_right_by_one_column
, and shift_shape_left_by_one_column
, which all internally use our _shift_by
method to move shapes around on the screen. The move_to
method is used by our board to move the shapes into the positions described by methods like rotate_clockwise
and shift_shape_right_by_one_column
, and finally, our random
method returns a randomly generated, complete tetromino (like a "Z"-shape or "J"-shape).
Finally, we have seven different shapes (SquareShape
, TShape
, LineShape
, SShape
, ZShape
, LShape
, and JShape
) that all inherit from Shape
. Check out the comments to see how their orientations work (each shape has a number_of_orientations
and a dict returned by block_positions
that describes those orientations). For example, a square only has one orientation, since rotating it doesn't really do anything, but a "T" shape has four: ⊢, ⊤, ⊣, and ⊥.
main.py
Finally, our main.py
file ties everything together neatly for us by creating a new Game
instance and setting up a signal handler to quit the game if player types Ctrl+C
. This file is so short that we can actually just reproduce it here:
import signal
import sys
from board import BoardDrawer
from game import Game
def main():
Game().run()
def signal_handler(_signal, _frame):
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
if __name__ == '__main__':
main()
...and that's it! I hope you enjoyed this tutorial, and feel free to fork this REPL to add more functionality.
I kinda got tripped up by the fact that you can't spam spin to move across the bottom but overall an amazing recreation!