Game Tutorial: Tetris
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.
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:
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
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_shape handle adding the falling shape to the array of shapes that have already fallen,
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.
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).
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 class, by comparison, is pretty straightforward: here's where we put all the logic that runs the game itself. The important methods here are
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.)
__init__ method keeps references to our
BoardDrawer, keeps tracks of "ticks" through the game, and draws the board by calling
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
exiting the program). The
_tick methods are used to control the running and pausing of the game, and
update (different from the
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
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).
Block class is pretty simple! Here's the whole thing:
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.
__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_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_counterclockwise methods use
_rotate in order to rotate our shapes clockwise and counter-clockwise (respectively) during gameplay.
Next, we have four methods,
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
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 (
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 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:
...and that's it! I hope you enjoyed this tutorial, and feel free to fork this REPL to add more functionality.