Game Tutorial: Tetris
ericqweinstein (202)

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, exiting 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 Blocks), 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.

You are viewing a single comment. View All
KonradSmith (0)

I kinda got tripped up by the fact that you can't spam spin to move across the bottom but overall an amazing recreation!