Building Tetris in Pygame – Part III

# 005 Actual Code

Having just talked about testing, we’re going to take a short break and get a window on the screen. I’m not writing unit tests for this — it’s theoretically possible, but in the end simply running the program is a simpler way to find out if it works.

tetris.py:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys
import pygame

from pygame.locals import *

def terminate(error=0):
    pygame.quit()
    sys.exit(error)

def main():
    pygame.init()
    pygame.display.set_mode((400, 500))

    while True:
        for event in pygame.event.get():
            if event.type == QUIT:
                terminate()

if __name__ == '__main__':
    main()

This puts a blank, black window on screen until the user closes it. Not terribly impressive, but we have to start somewhere. Note that I’ve broken the cleanup code down into a separate terminate() function. While the interpreter will clean up after you when you exit, it’s still good style to clean up as many resources as you can before closing. This way, we can quit out easily from anywhere, without worrying about cleaning up perfectly in each instance.

Run it, and we get a window. Yay. (If you don’t, go find out why.)

Now lets write something a bit more complicated:

# 006 The GameState Object

We talked about the GameState object last time, let’s work on implementing it. To start with, I’ll test something easy, the dimensions of the well.

First, we’ll commit our previous changes and set up a new branch:

$ git add tetris.py
$ git status
...
$ git commit -m "Basic event loop"
$ git branch game_state
$ git checkout game_state
Switched to branch 'game_state'

Now we can write our test:

tests/test_state.py

# -*- coding: utf-8 -*-

import unittest

from tetris import state, const

class TestGameState(unittest.TestCase):

    def setUp(self):
        super(TestGameState, self).setUp()
        self.state = state.GameState()

    def test_init_well(self):
        self.assertEquals(len(self.state.well), const.WELL_H)

        for row in self.state.well:
            self.assertEquals(len(row), const.WELL_W)

There are a few points to note:

1. I’m importing two separate modules — tetris.const is going to only hold what would otherwise be global constants. Putting them in a namespace makes things that much cleaner. (We’ll abstract out the screen size here as well.)
2. I’m not testing the well with a 10×20 grid and .assertEquals. That’s typo-prone, hard to adjust if the spec changes, and a lot more work.
3. GameState.init_well() will be called on the initialization of the GameState object. There’s no need for it at any other time, so we can assume it’s not meant to be called by external code.

Let’s plug it into the test framework and check that the test fails:

run_tests.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from lib.test_runner import ModuleTestRunner

from tests.test_state import TestGameState

suite = ModuleTestRunner()

suite.addTestList("State", [TestGameState('test_init_well'),
                            ])

if __name__ == '__main__':
    suite.run()

Create tetris/state.py and tetris/const.py, just to shortcut one round of errors, and run our test suite:

$ touch tetris/state.py tetris/const.py
$ ./run_tests.py
Running tests:
STATE:      E

------------

In test_init_well (tests.test_state.TestGameState)
Traceback (most recent call last):
  File ".../tetris/tests/test_state.py", line 12, in setUp
    self.state = state.GameState()
AttributeError: 'module' object has no attribute 'GameState'

------------

1 tests run in 0.000260 seconds, 0 failures, 0 skipped, 1 unexpected errors.

Imagine that, our empty files are not enough to pass.

This is not a treatise on TDD, and this post is going to be long already, so I’m not going to show all my iterations and will just skip ahead to the code that passes:

tetris/const.py

# -*- coding: utf-8 -*-

SCR_W = 400
SCR_H = 500

WELL_W = 10
WELL_H = 20

tetris/state.py

# -*- coding: utf-8 -*-

from tetris import const

class GameState(object):
    """Object to store the current state of a Tetris game."""
    def __init__(self):
        super(GameState, self).__init__()
        self.init_well()

    def init_well(self):
        self.well = []
        for y in xrange(const.WELL_H):
            row = []
            for x in xrange(const.WELL_W):
                row.append(0)
            self.well.append(row)

Passing test and git commit:

$ ./run_tests.py
Running tests:
STATE:      .

------------

1 tests run in 0.000146 seconds, 0 failures, 0 skipped, 0 unexpected errors.
$ git add .
$ git status
...
$ git commit -m "Begin implementation of GameState class, init_well"

(Yes, commit after *every* red/green cycle. Local commit histories are easily cleaned up before a push, and it will make rolling back mistakes that much easier.)

Next, let’s set up our scoring and levels. Modern Tetris games have a variety of different scoring algorithms, with bonuses for clearing multiple lines. For now, we’re going to keep it simple: one point per line cleared, and a level up every 10 points.

Again, starting with some basic tests:

tests/test_state.py

  # ... in TestGameState
    def test_init_score_level(self):
        self.assertEquals(self.state.score, 0)
        self.assertEquals(self.state.level, 1)

    def test_line_cleared(self):
        self.state.line_cleared()
        self.assertEquals(self.state.score, 1)
        self.assertEquals(self.state.level, 1)

        for i in xrange(10):
            self.state.line_cleared()

        self.assertEquals(self.state.score, 11)
        self.assertEquals(self.state.level, 2)

(Don’t forget to add the tests to run_tests.py)

Passing these tests is simple:

class GameState(object):
    """Object to store the current state of a Tetris game."""
    def __init__(self):
        super(GameState, self).__init__()
        self.init_well()
        self.score = 0
        self.level = 1
  # ...

    def line_cleared(self):
        self.score += 1
        if self.score % 10 == 0 and self.score > 0:
            self.level += 1

Another test run and git commit…

$ ./run_tests.py
Running tests:
STATE:      ...

------------

3 tests run in 0.000270 seconds, 0 failures, 0 skipped, 0 unexpected errors.
$ git add .
$ git status
# On branch game_state
# Changes to be committed:
#   (use "git reset HEAD <file>..." to unstage)
#
# modified:   run_tests.py
# modified:   tests/test_state.py
# modified:   tetris/state.py
#
$ git commit -m "GameState score/level updates"

There is more to be done with GameState, so we will be back to it next time.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s