So here we have our tetris clone, complete with appropriately Russian background, and done in less than 400 significant lines of Python. The Tetrominos–as the tetris shapes are called– fall at a constant rate, and the player can use the direction keys to rotate the falling tetromino or move it right, left, or down. When the falling tetromino can’t move further down, any full line gets cleared, and the next Tetromino spawns. The player can pause the game with the space bar, and when the falling tetromino can’t move down but is still in the starting zone (the redish area at the top), the game ends. Notice we don’t have any sound, as we’ll introduce sound in a later project.
The bulk of the code is organized into classes. Only two of these classes, though, Tetromino and TetrominoType, have multiple instances. The rest are treated as what are called ‘singletons’ in Object-oriented terminology. A singleton is a class which is only meant to be instantiated once. In truth, a singleton class acts more like a module of code than as a proper data type. You’ll see singletons used frequently in a language like Java, where the language forces everything to go inside classes; effectively, singletons in Java are an end-run around the object-oriented design structure which Java imposes. In Python, singletons are stylistically useful as what you can think of as ‘submodules': rather than create a .py file for each of these pieces, we put them all in one file, each as their own class.
The Board class represents the play area, a coordinate grid where the Tetrominos go.
And whereas each Tetromino on the board is represented by an instance of Tetromino, each Tetromino object has a property for its TetrominoType, which is one of the 7 instances representing the 7 shapes of Tetromino. So while the number of Tetromino instances changes as the game progresses, and while each Tetromino instance has a property referencing a TetrominoType, there are always 7 and only 7 TetrominoType instances throughout the game.
InfoDisplay displays the text messages, including the number of lines cleared and the ‘paused’ and ‘gameover’ messages.
Input translates the user input events into the game commands, that is moving the falling tetromino and pausing the game. Be clear, though, that the actual pausing and moving are done by the Game object and Board object, respectively. The Input object is there simply to translate the input events. Given our very simple user input scheme, the Input class is perhaps an unnecessary layer of abstraction, but it will be a useful pattern to expand upon in our future projects when the user input gets more complicated.
GameTick is a simple timer used by our Game class to determine when to move the falling Tetromino down and clear lines.
The Game class does the high-level game logic, effectively tying together the board, infodisplay, and input objects.
So these are our classes. None of the classes inherit from any other, but their composition relationships still form a dependency hierarchy: the game object contains the board, input, infodisplay, and gametick instances, and the board instance maintains a list of the tetrominos on the board, all of which in turn reference a TetrominoType instance. (Be clear that the composition relationships of a program don’t always form a neat hierarchy, but they do in this program. )
So all of these classes are in the module gametypes.py, but the resource loading, event handling, game loop setup, and most of the object instantiation is done in our main module, tetris.py, which is the module we run to run the game.
The tetris.py module starts out importing pyglet and our gametypes module, then sets a few constants. Width and height are the dimensions of our window. Board_x and board_y are the display coordinates of the lower-left corner of where the board is drawn. Grid width and grid height specify the block width and height of the our board. 10 by 20 seems to be the norm in most Tetris games I’ve seen. Block size sets the size in display coordinates of each block of the board.
We then create our Pyglet window with WIDTH and HEIGHT, being sure to set vsync to false.
We then load the resources of our game, in this case just two image files, the background image on top of which everything else is drawn, and an image containing the different colored blocks for the different Tetromino types. The resource.image function returns a Pyglet image object. As we’ll see, we can draw these images anywhere into the window with their blit method. The term blit is an old term derived from ‘bit block transfer’. In graphics, ‘blit’ means to copy image data from one image to a framebuffer or another image, either in whole or in part. For reasons we’ll discuss in a later video, using blit is the least efficient way to draw in Pyglet, but it’s simple and suitable for our simple game of Tetris.
Once we have our two image objects, we then invoke the TetrominoType class’s static method called classInit. A static method, recall from the video on object-oriented programming, is a method which is really not a method but rather just a function that happens to live in the namespace of a class. That’s why we can invoke classInit here via the TetrominoType class object itself rather than any instance of TetrominoType. In fact, at this moment, there aren’t any TetrominoTypes in the program. The purpose of classInit is to, as the name implies, initialize the class, that is, set up some properties of the class object. The purpose will be clear when we look at the code.
In any case, now that our resources are loaded and any classes that need initialization have been initialized, we then set up our initial game state. This means creating the object hierarchy we just looked at: our top-level Game object requires an instance of Board, InfoDisplay, and Input, so we create those instances first and then pass them to the Game constructor. The purpose of the other constructor arguments here we’ll discuss when we look at the classes.
Looking at the second half of tetris.py, we register two event handlers on our window: ‘on_key_press’ and ‘on_text_motion’. The on_key_press event triggers when we push down any keyboard key. The on_text_motion event triggers when we push down a direction key–up, down, left, or right–but it also triggers repeatedly as long as the user holds down the key. This repeated triggering, though, starts only after a short delay, the precise delay length of which is a system-defined keyboard setting. In both of these handlers, we pass the input to a method of our Input object, which is responsible for interpreting the events.
We also of course register an on_draw handler, which simply invokes the draw method of the game. If our game didn’t redraw the whole window every frame, we would want to invoke window.clear to draw on a blank slate. In this case though, the first thing game.draw does every frame is redraw the background over the whole window, so we don’t need to clear.
With our handlers in place, we also need an update function to update the state of our game on a regular basis. So the update function here is scheduled to run every 1/60th of a second. The real work, though, as you can see, is done in game.update. In other games, we’d pass dt, the delta time, the actual time passed since last invocation of the update function, we’d pass dt to game.update so that our game logic can account for variances in the invocation rate. In Tetris though, the variances don’t make a substantial impact on the gameplay, so we can ignore dt.
Finally, with our resources, game state, event handlers, and update function in place, we start the event loop with app.run and our game begins.
So that’s all of tetris.py. Looking now at gametypes.py, we again start by importing pyglet. We also import random, a standard Python module, which in this case we’ll use to select a random item from a list.
Then looking first at the TetrominoType class, its constructor takes two parameters, blockImage, and localBlockCoordsByOrientation, which are simply assigned to instance properties of the same name. BlockImage is a pyglet image object which is what we use to draw the Tetrominos. Each of the seven tetromino types will each have its own block color. The local block coords by orientation argument should be a dictionary of four items, each with an orientation for the key and a list of four coordinates for the value. We’ll represent an x, y coordinate as a tuple with two integers, for example, the x, y coordinate 3, 5 is represented as a tuple of 3 and 5. Recall that a tuple in Python is like a list but un-modifiable: once you create a tuple, you can’t change how many objects it lists or which object it lists (the objects themselves, though, may still be modifiable). So in any case, the ‘localblockcoordsbyorientation’ property contains the four block coordinates of the tetromino type in all four rotation orientations: up, down, left, and right. This’ll be clearer if we look at the creation of the seven TetrominoType instances, which is done in classInit.
As mentioned, classInit is a static method, which is why we use the staticmethod decorator. Staticmethod is a python built-in function which wraps a function object as a staticmethod object. A staticmethod object behaves just like a function object but with the difference that, when defined at the top-level of the class, it is not treated as an instance method, meaning there is no implicit argument for an instance object. That’s why this method doesn’t have a first parameter called self. So classInit becomes a staticmethod attribute of the TetrominoType class object itself, which is why we invoke it as TetrominoType.classInit.
Anyway, what classInit does is create an image for each color of block. It does this using the get_region method, which returns an image which is a portion of some other image. The first line assigns to green an image object which is the portion of blocksImage with its lower-left corner at 0, 0 and which is blockSize by blockSize in dimensions. The remaining calls all do the same thing, but starting at coords one blockSize further right. So we end up with image objects representing each block-sized portion of the ‘block.png’ file. As we’ll see in later projects, Pyglet provides more convenient methods for getting regularly spaced portions from an image.
The rest of classInit assigns to the TetrominoType class, a property called TYPES with a list of seven TetrominoType instances, which we create here. We see the first two types. For each instance, for each type, we have four lists of coordinates, one for each rotation orientation. These coordinates are expressed in local terms, not in terms of the whole board grid. When we place tetrominos on the board, we will translate from these local coordinates to board coordinates. So for example, because a square looks the same when you rotate it 90 degrees, the square type has the same coordinates in all four rotations: (0, 0) (0, 1), (1, 0) and (1, 1). If a square tetromino is located, say, at board coordinates 5, 5, then we’ll need to translate the local coordinates of its blocks to (5, 5), (5, 6), (6, 5), and (6, 6).
Lastly, the tetrominoType class has another static method which simply returns one of the seven types at random. This is done with the Python module random, which has a function choice that takes a list and returns a random element of that list. So when we pass the TYPES list to choice, it returns one of the types at random.
Looking now at the Tetromino class, the class itself has a few constants. In the top line, range(4) returns a list with the numbers 0, 1, 2, and 3. As a convenience, we can assigns the respective elements of a list to variables by listing them with commas, so here, RIGHT is assigned 0, DOWN 1, LEFT 2, and UP 3. These constants are used as enumeration values of the four rotation orientations. It’s arbitrary what value we assign to these variables as long as they are all different and stay unchanged for the life of the program. But we can then use these enumeration variables to refer to the four orientations, which is what we did when we created the TetrominoType instances.
The clockwise_rotations dictionary defines the clockwise transition through the orientations: we go from right to down, down to left, left to up, and up to right.
In the constructor, we give the Tetromino instances five properties: an x and y coordinate designating the Tetromino’s location on the board, a random TetrominoType, the starting orientation RIGHT, and a list of the coordinates on the board of the Tetromino’s blocks.
The calcBlockBoardCoords method which creates these coords is quite simple. First we get the local block coords for the type and orientation of this Tetromino, which are in the localBlockCoordsbyOrientation dictionary of the type instance. We translate from each local coordinate to a board coordinate by simply adding self.x to the x component and self.y to the y component. And remember that each x y coordinate is expressed as a tuple of two integers. So we create a new list gridCoords, iterate over the localBlockCoords, calculating each new gridCoord by retrieving the local x as coord and adding self.x and retrieving the local y as coord and adding self.y, and then appending the new coordinate to gridCoords. Once we have our new list of translated gridCoords, we can return it.
The tetromino class also contains methods for moving the tetromino on the board. We can set an arbitrary position, which we do when we first place a tetromino on the board. We can also move the Tetromino down, up, left, right, or rotate clockwise or counterclockwise. To move down, we simply decrement self.y; to move up we increment self.y; to move left we decrement self.x; and to move right we increment self.x. To rotate clockwise, we use the CLOCKWISE_ROTATIONS dictionary, and to rotate counterclockwise we use it three times (because rotating clockwise 3 times is the same as rotating counterclockwise once). Note that, very importantly, after all of these modifications, the blockBoardCoords are recalculated.
The command method is a convenient interface for invoking these methods. The command argument is one of four enumeration values from the Input class: MOVE_DOWN, MOVE_RIGHT, MOVE_LEFT, AND ROTATE_CLOCKWISE. The undoCommand method does the inverse action of the command argument, for example, MOVE_DOWN triggers a call to moveUp.
When a row on the board becomes full, it gets cleared. This requires removing all blocks from the row and adjusting all blocks above the row down by one. Because this is only done to settled tetrominos and never a still moving tetromino, this can be done simply by directly modifying the blockBoardCoords properties. So the clearRowAndAdjustDown method of Tetromino does this for one tetromino instance. We first create the list that will replace the current list of blockBoardCoords. Then we iterate through the current list: for each coord with a y component greater than the row to clear, we adjust the y component down one and add the adjusted coordinate to the new list; for each coord with a y component less than the row to clear, we add coordinate as is. So notice that we excluded any coordinate with a y component equal to the row to clear. This effectively removes those blocks from the Tetromino. So we assign the new coords list to self.blockBoardCoords, and return True if the Tetromino still contains any blocks, otherwise False.
Finally, the Tetromino draw method blits the tetromino on screen. To do so requires translating the grid coordinates of its blocks to screen coordinates, but this is left up to the Board class. So here we expect to receive the screen coords as an argument. Then at each screen coordinate, we blit the blockImage of this Tetromino’s type.
The Board class includes a few constants: STARTING_ZONE_HEIGHT defines how many rows exist at the top of the board. Next_x and next_y define the grid location of where to display the next tetromino, the tetromino that will fall after the current falling Tetromino. By giving next_x a negative value, the next tetromino actually appears outside the board to the left, which is where we want it. (Arguably the next tetromino isn’t properly a part of the board because it’s not actually on the board, but it was simplest to implement this way if a bit hackish.)
Then in the Board constructor, we have x and y parameters that define lower left corner screen coordinates of the board, gridWidth and gridHeight which define the grid dimensions, and blockSize, which specifies the size of each block in screen coordinates. The spawnX and spawnY properties define the grid coordinate at which falling tetrominos spawn. Note that there isn’t any particularly good reason why x, y, gridWidth, gridHeight, and blockSize are all paramaters but spawnX and spawnY are not. In general, you parameterize those properties which you want to be configurable externally from the class. In this case, it just seemed to make sense to make these properties externally configurable but the spawn coordinates not externally configurable.
In any case, we also have properties for the currently falling tetromino, the tetromino that will fall after, and all other tetrominos in a list. The fallingTetromino and nextTetromino properties are configured by the spawnTetromino method, and the tetromino list starts out empty.
SpawnTetromino is not just called in the constructor but also every time a new falling tetromino is needed. The next tetromino becomes the falling tetromino, and a new Tetromino is created to replace nextTetromino. Both tetrominos are then positioned, the falling tetromino in the starting zone and the next tetromino off to the left of the board.
The commandFallingTetromino method sends a move command to the falling tetromino, but after the move, it tests whether the current position is valid, and if not, undoes the command. Invalid position, here, means that any block of the falling Tetromino is either out of the board bounds or overlaps another Tetromino. So we perform the validPosition test by first collecting all coordinates of the blocks of the other tetrominos. We do so by iterating through the list of tetrominos and adding their coordinates to the nonFallingBlockCoords list with the extend method. (The extend method of a list, recall, takes another list as argument and appends every item of the argument.) Once we have this list of coordinates, we then iterate through the block coordinates of the falling tetromino, testing whether the coord is out of bounds or whether it matches any coord in nonFallingBlockCoords. If either is true, the position is invalid and so the method returns False. If we get through all these tests without finding any out-of-bounds or overlapping coordinates, the method returns True. (If you’re not familiar, the Python in operator returns True if its left operand equals any item in its right operand. So here the expression ‘coord in nonFallingblockCoords’ returns true when coord equals any item in nonFallingblockCoords.)
The findFullRows method returns a list of indices of the rows which are fully occupied by Tetromino blocks. So for example, if just the bottom two rows are full, this method returns a list of 0 and 1. It does so by first constructing a list of all tetromino coordinates, just like in isValidPosition. We then tally up the blocks in each row in a dictionary rowCounts, whose keys are row indices and whose values are the tally for that row. So first we iterate through all row indices, assigning each row index key the value 0. Then we iterate through the blockCoords, incrementing the value of the appropriate row index for each. For example, when coord, the y component of the coord, is 5, we increment the tally in rowCounts for the key 5. Once we have our tallies, we create our list of fullRows, adding every index from rowCounts with a tally equal to the grid width of the board. (Recall that when we iterate through a dictionary, as we do here, the keys are in no discernible order. Consequently, our indices in fullRows will be in no particular order.)
To clear a single row of the board, we have clearRow, which invokes on each Tetromino the method clearRowAndAdjustDown. If the Tetromino still has remaining blocks, clearRowAndAdjustDown will return True, in which case we add it to the new list of tetrominos. So Tetrominos without remaining blocks effectively get discarded.
The clearRows (plural) method does the same, but to multiple rows. Because clearing a row moves every block above down by one, we need to clear the rows from top to bottom. This simply requires sorting the row indices in descending order, which we do with the list sort method with argument reverse equals True.
Board’s updateTick method, as the name implies, updates the board for every tick, the moments where the falling tetromino moves down whether the player wants it to or not. The complicated part is when the tetromino can’t move down any further. We detect this by moving the tetromino down regardless and then testing whether the position is valid. If invalid, we undo the move, add the falling Tetromino to the tetrominos list, find the full rows and clear them, test if the game is lost based on whether the falling tetromino is still in the start zone, and assuming the game is not lost, spawning the next tetromino. Finally, updateTick returns two values in a tuple: the number of rows cleared, and a boolean of whether the game is now lost.
The isInStartZone test is done simply by testing whether any block coord has a y component greater or equal to the gridHeight.
Lastly in the Board class, we have a method for converting grid coordinates to screen cordinates. Self.x and self.y, recall, are the screen coordinates of the lower left corner of the board, so we can get a screen coordinate simply by adding these components to the grid components multiplied by the blockSize. For example, for grid cordinate x = 5, we multiply 5 times the blockSize and then add that to self.x.
Once we have screen coordinates for a Tetromino, we can draw it. So in the Board draw method, before drawing each tetromino, we first get its screen coordinates to pass to the tetromino’s draw method. We first draw everything in self.tetrominos, then the falling tetromino, then the next tetromino.
The infodisplay class has a few constants, rows_cleared_x and rows_cleared_y, which are the coordinates at which to draw the number of rows cleared. In the constructor, we create 3 lables: one for the rows cleared, one for the game paused message, and one for the game over message. The paused and gameover messages are centered using the window dimensions, which is why the constructor takes the window object as a parameter. Lastly, we have two flags, showPausedLabel and showGameoverLabel, which determine whether the paused label and gameover label get drawn. To show these labels, we set the flags to true, and to hide them we set the flags to false.
The setRowsCleared method simply updates the number of rows cleared that is displayed by the rows cleared message. The draw method of course draws the labels. In the case of the paused label and gameover label, their respective flags must be true or else they won’t get drawn.
The Input class contains enumeration values representing the five user actions: TOGGLE_PAUSE, MOVE_DOWN, MOVE_LEFT, MOVE_RIGHT, and ROTATE_CLOCKWISE. In the constructor, we simply create one property, action, which holds the last user action. In a more complicated game, we would possibly want to retain a recent history of user inputs for more complicated behaviors, but in our simple Tetris game, we get away with just keeping track of the last user action.
In the processKeypress method invoked when an on_key_press event occurs, if the key pressed was the spacebar, the action is set to TOGGLE_PAUSE. In the processTextMotion method inovoked when an on_text_motion event occurs, the left key sets the action to move left, the right key sets the action to move right, the up key sets the action to rotate clockwise, and the down key sets the action to move down.
Lastly, the consume method returns the last action, setting action to None because we want each user action performed only once until there’s more user input.
The GameTick class constructor sets two properties, tick and started, by default to false. The idea here is that we invoke the isTick method in each iteration of our game loop, but we only want it to ‘tick’–that is, to return True–at regular intervals. In the first call to isTick, self.started, by default, will be false, so we take the first branch of the if-else ladder: self.started gets set to True, the setTick function gets scheduled to run after nextTickTime elapses, and isTick returns false. Once nextTickTime elapses, setTick will run, setting self.tick to True, such that the next invocation of isTick will take the second branch, setting self.tick to False, scheduling setTick to run again after nextTickTime elapses once more, and isTick returns True. All the isTick invocations in between will return False. So, as long as we keep invoking isTick with the same nextTickTime argument, it will keep ‘ticking’–that is, return True–at regular intervals. If we desired, though, we could change the nextTickTime on each tick, so we could have our Tetris game speed up as the player clears more and more lines. That’s a feature I’ve omitted, but it would probably take only a few more lines of code in our Game class.
Looking finally at the Game class, its constructor sets properties for the four parameters passed in: the board object, the infodisplay object, the input object, and the backgroundImage. In addition, self.paused and self.lost are set to False, numRowsCleared starts out at 0, tickSpeed is set to 0.6 (as in 6/10ths of a second), and ticker holds the GameTick object we’ll use to trigger the board updateTicks. (If you’re wondering why the GameTick instance is created in the constructor while the board, infodisplay, and input instances get passed as arguments, one reason is that two of those objects require their own constructor arguments, but the real reason is that it’s generally better object-oriented practice to create top-level objects separately. In this particular case, it would be neat and convenient to just create these instances in the Game constructor, but that kind of object bundling creates inflexibility that hurts us when we try to evolve a codebase.)
In any case, the addRowsCleared method updates the tally of cleared rows and adjusts the rows cleared message accordingly. The toggle pause method toggles the self.paused property and sets the showPausedLabel flag accordingly.
The Game update method, which remember is scheduled to run every 60th of a second, updates the game state: if lost is true, the game is over, so we simply show the game over label. If the game is not lost, we get the last command from the input object. If the command is TOGGLE_PAUSE, we invoke togglePause. Then if the game is not paused, we first test if the command is not None and if the command is not equal to TOGGLE_PAUSE; if so, the command is an action concerning the falling tetromino, so we pass the command to board.commandFallingTetromino. Next, if the ticker’s is tick tests True, we invoke board.updateTick, which recall returns a two-element tuple with the number of rows it clears and whether the game is now lost. Lastly, we add the rowsCleared.
The very last piece of all is the game draw method, which recall is the only thing invoked by the on_draw event. This draw method first blits the background image over the whole window, then draws the board, and last draws the infoDisplay. Be clear that the draw order of overlapping elements matters, so we must draw the backgroundImage first or else it will draw on top of the board and infoDisplay, in which case we’d never see the board or the text messages.