Learn to Code via Tutorials on Repl.it!

← Back to all posts
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.