Noughts and Crosses with PyGame Part 1

A Python and PyGame implementation of the Noughts and Crosses / Tic Tac Toe game.

Overview

A while ago I wrote a Noughts and Crosses game (or Tic Tac Toe if you prefer) in Python with a Curses-enhanced console user interface. I kept the game logic and user interface separate so that it would be possible to write different UIs with no change to the "brains" of the game.

I have at long last got round to writing a PyGame front end and in this, the first article of two, I will show how I created the actual UI itself and added event handlers. In the second part I'll connect it to the class providing the underlying logic to create a fully working game.

This is my first full PyGame project and I am very aware of its many shortcomings.These are partly due to my novice status and also partly due to the nature of PyGame compared to other major application frameworks I am familiar with, in particular Microsoft .NET WinForms and WPF, and also Tkinter and PyQt. While working on this project I collected a number of ideas for improving its overall architecture so while this and the next article provide a full implementation there will be a number of follow-ups chronicling my quest for perfection!

So what am I actually going to build here? This is a screenshot of the complete PyGame UI in which you, a specimen of the primitive lifeform known as Homo Sapiens, play the omniscient and all-powerful Computer, although you do have the option to impose a bit of human stupidity on its otherwise formidable intelligence.

Noughts and Crosses or Tic Tac Toe with PyGame and Python

The blackboard and the chalky bits are PNG graphics, provided along with the source code and displayed courtesy of PyGame. Obviously the game itself takes up most of the window but we also have buttons to start a new game after one has been completed and to force the computer to make the first move. There are also three radio buttons to set the level. These controls are entirely "home made" although my long list of possible enhancements includes developing reusable object-oriented controls such as those you may be familiar with from, for example, Kivy or PyQt. There are already a number of PyGame GUI frameworks including those listed on the PyGame site:

PyGame GUI frameworks

I'll probably try some of these out after I've had a go at writing my own.

The only other functionality provided here in Part 1 is handling mouse clicks. Clicking on one of the squares will print the number 1 to 9 in the console (not 0 to 8!), clicking a button will print "new" or "start" in the console and clicking one of the radio buttons will show or hide the little circle in the middle of each button. In Part 2 we'll connect these up to the game to complete the project.

This article is not intended to be an introductory PyGame tutorial but is very simple and therefore suitable for anyone with no PyGame experience, although it does require Python knowledge including classes and object oriented programming.

A Few Words About PyGame

As you may already know, PyGame is a very simple framework providing basic functionality for writing games in Python. Its core features are:

  • Drawing graphics opened from files such as png and jpeg

  • Drawing simple shapes: lines, rectangles, circles etc. as well as text

  • Handling keyboard and mouse events

  • Playing sounds

In this project I will only be drawing png images and handling mouse clicks. (There is no sound - you don't really want to hear chalk being scraped across a blackboard do you . . . ?)

PyGame is just a quick pip install away - this is its page on PyPI:

https://pypi.org/project/pygame/

PyGame also has its own site:

https://www.pygame.org

The Project

This project consists of two source code files, nac.py and nacpygame_1.py and a few graphics files. They can be downloaded as a zip, or you can clone/download the Github repository if you prefer. The zip and repository also contain nacpygame_2.py, the completed game which will be the subject of the next article.

Source Code Links

ZIP File
GitHub

The various graphics files are in a subfolder called graphics. Please make sure this survives unscathed when you unzip or clone the files as sometimes uncompressing or cloning can mess with subfolders. If all the files end up in one place just manually move them to a graphics folder.

The Code

I won't include nac.py here as the source code and description are in the article on the console version. You might want to read that first but if you are just interested in the PyGame front end that's fine - you can treat the NaC class as a "black box". However, it is necessary to understans a bit about how it interacts with user interface code.

  • Front end code creates an instance of the NaC class, passing two functions:

    • on_change, called by NaC whenever a state change requires a UI update

    • on_game_over, called by NaC when the game ends, requiring the UI to notify the player

  • The UI notifies the NaC instance of player actions by calling the following methods:

    • human_move - when the player clicks a square

    • new_game - when the player clicks New

    • computer_move - when the player clicks Start

    • __level_changed - when the player selects a different level

It should be clear from the above that the NaC class has no knowledge of the user interface. It calls on_change and on_game_over without needing to know what they do or how they do it. It also runs human_move, new_game, computer_move and __level_changed without needing to know anything about the UI or how the player interacts with it. In this way we can create front ends using a console, PyGame, Pyglet, PyQt or anything else without changing the NaC class. You could even create an NaC object from, say, Django or Flask with an HTML/JavaScript front end communicating via using AJAX.

So now we can look at the PyGame user interface which is implemented as a class called NaCUI. Remember this isn't the completed game yet, it just creates the UI and event handlers. Hooking it up to the NaC class comes in Part 2.

nacpygame_1.py

from collections import namedtuple

import pygame


UIrect = namedtuple("UIrect", "x, y, w, h, func")


class NaCUI(object):

    def __init__(self):

        pygame.init()
        pygame.display.set_caption("CodeDrome NaC")
        self.win = pygame.display.set_mode((600,800))

        self.run = True
        self.graphics = self.__init_graphics()
        self.uirects = self.__init_rects()
        self.clock = pygame.time.Clock()        
        self.level = "average" # temporary variable

        self.__draw_game_window()

        self.__event_loop()


    def __init_graphics(self):

        graphics = {"background": pygame.image.load('graphics/blackboard_600x800.jpg'),
                    "grid": pygame.image.load('graphics/grid.png'),
                    "nought": pygame.image.load('graphics/nought.png'),
                    "cross": pygame.image.load('graphics/cross.png'),
                    "levels": pygame.image.load('graphics/levels.png'),
                    "radio_on": pygame.image.load('graphics/radio_on.png'),
                    "start": pygame.image.load('graphics/start.png'),
                    "new": pygame.image.load('graphics/new.png')}

        return graphics


    def __init_rects(self):

        """
        Top left coordinates, size and function
        for each area we wish to handle mouse clicks for
        """

        rects = [UIrect(0,   0,   200, 200, lambda: print("1")), 
                 UIrect(200, 0,   200, 200, lambda: print("2")),
                 UIrect(400, 0,   200, 200, lambda: print("3")),
                 UIrect(0,   200, 200, 200, lambda: print("4")),
                 UIrect(200, 200, 200, 200, lambda: print("5")),
                 UIrect(400, 200, 200, 200, lambda: print("6")),
                 UIrect(0,   400, 200, 200, lambda: print("7")),
                 UIrect(200, 400, 200, 200, lambda: print("8")),
                 UIrect(400, 400, 200, 200, lambda: print("9")),
                 UIrect(28,  612, 246, 76,  lambda: print("new")),
                 UIrect(28,  710, 246, 76,  lambda: print("start")),
                 UIrect(322, 616, 44,  44,  lambda: self.__level_changed("idiot")),
                 UIrect(322, 682, 44,  44,  lambda: self.__level_changed("average")),
                 UIrect(322, 744, 44,  44,  lambda: self.__level_changed("genius"))]

        return rects


    def __point_in_rect(self, pos, rect):

        """
        Check whether a coordinate is within a rectangle
        """

        x2 = rect.x + rect.w
        y2 = rect.y +rect.h

        in_rect = (rect.x <= pos[0] < x2 and
                   rect.y <= pos[1] < y2)

        return in_rect


    def __click_to_func(self, pos):

        """
        Iterates rectangles and calls corresponding function
        for any which have been clicked
        """

        for rect in self.uirects:

            if(self.__point_in_rect(pos, rect)):

                rect.func()


    def __handle_left_mousebuttondown(self, pos):

        self.__click_to_func(pos)


    def __level_changed(self, new_level):

        self.level = new_level

        self.__draw_game_window()


    def __draw_game_window(self):

        self.win.blit(self.graphics["background"], (0,0))
        self.win.blit(self.graphics["grid"], (0,0))
        self.win.blit(self.graphics["levels"], (300,600))

        if self.level == "idiot":
            self.win.blit(self.graphics["radio_on"], (330,622))
        elif self.level == "average":
            self.win.blit(self.graphics["radio_on"], (332,686))
        else: # "genius"
            self.win.blit(self.graphics["radio_on"], (334,748))

        self.win.blit(self.graphics["start"], (0,700))
        self.win.blit(self.graphics["new"], (0,600))

        # these 2 lines are not part of the final game
        # and are just here to demo the nought and cross graphics
        self.win.blit(self.graphics["nought"], (200,0))
        self.win.blit(self.graphics["cross"], (400,400))

        pygame.display.update()


    def __event_loop(self):

        """
        Checks event queue every 50ms
        for QUIT or MOUSEBUTTONDOWN
        """

        while self.run:

            self.clock.tick(50)

            for event in pygame.event.get():

                if event.type == pygame.QUIT:

                    self.run = False
                    pygame.quit()

                elif event.type == pygame.MOUSEBUTTONDOWN:

                    if event.button == 1:
                        self.__handle_left_mousebuttondown(event.pos)
            
                        self.__draw_game_window()


game = NaCUI()

Preliminaries

At the top of the file we import namedtuple and pygame, and then define a namedtuple called UIrect. If you're not familiar with namedtuples they are tuples which have fields indexable by name like a dictionary. This is the Python 3 documentation.

https://docs.python.org/3/library/collections.html#collections.namedtuple

The UIrect namedtuples will allow us to map rectangular areas of the UI (defined by the coordinates of the top left corner and size) to corresponding functions which are called when the player clicks the rectangles.

__init__, Graphics and Click Positions

There's quite a lot going on in __init__ which is central to the whole program so it's worth spending a bit of time looking at it carefully.

Firstly we need to call pygame.init, and then set the window caption and size.

The run variable is used as a terminating condition for the event loop, and graphics and uirects are created in the next two functions as we'll see in a moment. The clock allows us to pause within the event loop, and level is used in this partial solution to demo the radio buttons. In the finished game the level is part of the NaC instance.

Finally we need to call functions to draw the game for the first time and enter the event loop.

The __init_graphics function called by __init__ creates and returns a dictionary of all the graphics used by the game, each being opened with the pygame.image.load method.

The __init_rects function creates a list of UIrect namedtuples, one for each of the nine squares in the game, and one for each of the buttons and radio buttons. At the moment they just print to the terminal or change the radio buttons.

More sophisticated frameworks abstract away the creation of controls and their event handlers but here I have included all the necessary code within the class. As I mentioned above one of my plans for PyGame enhancements is to do just that but for now I'll go with this technique.

Mouse Click Event Handling

The next four functions are used to deal with mouse clicks. The first, __point_in_rect, is called by the following function and figures out whether a given mouse click position is within a given rectangle and returns a Boolean value.

The __click_to_func function iterates the UIrect namedtuples, calls __point_in_rect and if the mouse click is within a rectangle calls the corresponding function. The loop does not terminate if it finds a clicked rectangle but carries on iterating. This allows for overlapping rectangles; there are none in this game but it might come in useful in the future. "Never say never".

The __handle_left_mousebuttondown function, which will be called in the event loop, simply calls __click_to_func. The reason for separating out the __click_to_func code is so that more than one user action can be mapped to a single function, for example some actions may be carried out either by the mouse or keyboard. Again, future-proofing.

The last of this set of functions is __level_changed which is mapped to radio button clicks, and is temporary code just to demo the radio buttons.

Drawing the Game

Most of this function consists of a lot of "blitting" which is short for "bit block transfer", an efficient way of drawing graphics to the screen. Basically the blit method just tells PyGame to draw a specified graphic at a certain location, (0,0) being top left. Remember that all the graphics live in a dictionary, self.graphics.

The background, grid and levels radio buttons are always drawn, as are the Start and New buttons. The location of the little circle (does it have a name?) to show which radio button is selected is of course conditional on the level, and just to check they work I have hard-coded a nought and a cross.

Blitting draws to an off-screen memory buffer so it's necessary to call pygame.display.update() to actually show the graphics. (I suspect forgetting this step has led to endless frustration. "Why is the $%&#*@% screen blank? AAARRRGGGHHH!!!")

The Event Loop

The last function is __event_loop which sets off an infinite (don't worry!) while loop. Every 50 milliseconds this iterates the list of events returned by pygame.event.get(). If one of the events is pygame.QUIT (when the user clicks the X in the top right corner) it sets self.run to False and calls pygame.quit. (See, I told you not to worry about the infinite loop didn't I?).

If the event is MOUSEBUTTONDOWN then __handle_left_mousebuttondown is called with the position. As we saw above this checks if the mouse was clicked in one of the rectangles of interest and if so calls the relevant function.

Creating an Instance

To start the application we just need to create an instance of NaCUI which is done in the last line. It's very unusual to create an object in the same file in which its class is defined and I've probably never done it before. However, using a separate file with just this one line would be a bit ridiculous so I decided to forego convention and best practice.

Running the Program

If you run the program like this . . .

Running the Program

python3 nacpygame_1.py

. . . you'll see the GUI I showed earlier. If you click one of the grid squares or a button the corresponding text will appear in the console, and also you can select the radio buttons.

I hope you'll agree we have made good progress so far. Part 2 coming soon . . .