Python: Rock, Paper, Scissors but polished

Python: Rock, Paper, Scissors but polished

.. and finally on GitHub.

·

7 min read

During this series, we created a fun little project that's basically a really convoluted version of Rock, Paper, Scissors using Tkinter:

Bildschirmfoto 2021-12-17 um 14.38.42.png

Introduction

The game is easily expandable by creating more JSON-files and there's really not much more to add. In terms of functionality. There's however some polishing to do.

For private projects and just trying things out, this is most likely never relevant. If you're intending to share the project with your school, friends or on GitHub, you might want to polish the project a bit, even if it's boring.

Let's start from the top, there isn't really "right" or "wrong" when optimizing and cleaning your code. With business projects there's usually some clean code guidelines or some best practices to follow but for private projects or beginners, there's Google and posts like this one.

Folders and Files

I always recommend to have a folder for all your "let me just quickly try this"-scripts and then subfolders for everything that's actually going to become a project. In my case for our RPS-game, I just started coding into random files in my project folder:

Bildschirmfoto 2021-12-17 um 15.21.54.png

So, let's clean this up and create a folder and move everything that's project-related into it:

Bildschirmfoto 2021-12-17 um 15.22.45.png

I recommend to name the folder whatever the project will be called on GitHub or SVN or whatever cool name it's supposed to have when sharing it.

Files: Splitting the code

Our code currently uses only one file - rps.py in my case. That's fine and it makes sharing it much easier. If we ever want to expand the game, we might want to think twice.

I usually create some fixed code files for whatever project (unless it's just examples) like I'm trying to follow an MVVM/MVC pattern. In our case, we can consider the following:

  • create a main.py script with all code for the main application
  • move all classes to their own script
  • move all constants to their own script

Classes

Since our code isn't very long and this is pretty much a tutorial, we'll do it even if it's not necessary. So let's start by moving our classes RpsGame and RpsElement to a new script rps_classes.py.

Don't forget to "import json" at the top and remove it from the main script.

Speaking of the main script, you'll now need to import the new script to be able to run it again:

from rps_classes import RpsGame

Currently, we won't need to explicitly import RpsElement as well.

Constants

Next: Constants. What constants you ask? Well, every bit of code that's a static string. This is great practice for localization. Create a file rps_constants.py and define away:

WINDOW_TITLE = "Rock Paper Scissors"

Back in the main script, add an import .. as line and modify the first couple of lines like this:

import rps_constants as rpsc

# Tkinter initialization
root = Tk()
root.title(rpsc.WINDOW_TITLE)

It's not needed to import as rpsc but it makes finding constants much easier. Sidenote: for localization you check the locale once in your code and then basically load the texts_enUS-file or the texts_deCH-file or whatever.

So there's not really anything right or wrong to do here, you can basically remove all static strings from your main script and move them all into constants but that makes code hard to read. A nice way to think about it is to extract everything that might need translation in the future and give them a very descriptive and very loud (=caps) variable name:

# Game related internal
GAME_CONDITION_WIN = "win"
GAME_CONDITION_LOSE = "lose"
GAME_CONDITION_DRAW = "draw"

# Game related external
GAME_TEXT_WIN = "You win!"
GAME_TEXT_LOSE = "You lose!"
GAME_TEXT_DRAW = "Oh .. a draw .."

In this case, for example, I overdid it a little which makes the code read:

    if(result == rpsc.GAME_CONDITION_WIN):
        lbl_result["text"] = rpsc.GAME_TEXT_WIN
        lbl_result.configure(background="green")
    elif(result == rpsc.GAME_CONDITION_LOSE):
        lbl_result["text"] = rpsc.GAME_TEXT_LOSE
        lbl_result.configure(background="red")
    elif(result == rpsc.GAME_CONDITION_DRAW):
        lbl_result["text"] = rpsc.GAME_TEXT_DRAW
        lbl_result.configure(background="grey")

It's still ok but it gets a little shouty. Especially the result-check can be optimized further but that's for another time. What I do want to point out is how to choose the variable names. I always group them according to what they define and add a prefix and some kind of description. Think: Does this name make enough sense so that even without the defining script, one can understand the code?

I'll also briefly mention that not only strings can be exported into constants. In our example it doesn't really make sense to have constants for row and column numbers, but we defined several width-parameters at some point..

I'll cut it short here, check the end for the full code and all the constants I created.

Comments

Comments are really important. If you code just about anything and try to make sense of it like 10 years later, there's going to be tears if there's no comments.

I can 120% recommend to use an IDE/Editor that can auto-generate some form of documentation comment. For Python and VS Code, I use Python Docstring Generator by Nils Werner.

Bildschirmfoto 2021-12-17 um 16.10.41.png

It's really easy to use and if your functions are getting quite complex, there's no way you'll miss any arguments or values if you're using an extension like this.

As an example, here's the generated and filled comment for our init-function for RpsElement:

    def __init__(self, name, win, lose):
        """Initialization method for RpsElement.

        Args:
            name (string): name of the RpsElement
            win (list of string): list with strings of RpsElements it can win against
            lose (list of string): list with string of RpsElements it will lose against
        """
        self.name = name
        self.win = win
        self.lose = lose

And don't worry. You'll learn and get used to which comments are helping you and which comments are not helpful at all. Everyone's a little bit different, but this is a great starting point.

I can also highly recommend to do inline comments whenever code is really important or complex or you simply don't want to figure out the same thing over and over again.

Optimization

Code Optimization can be done everywhere and depending on who you ask, there's always something to do. In our case, there's two glaring issues I want to tackle.

Removing unnecessary code

Code should be as long as it needs to be but as short as possible. In our case that means, we can remove the lose-list entirely. For some reason I thought we needed it but after I prepared everything I thought I'd leave it in especially for this section of the tutorial.

Currently, we're checking if it's either a win or a loss and if it's neither, we call it a draw. The much easier and cleaner way would be to check if it's a draw first (since it's the shortest check), then check if we're winning and if it's neither, it has to be a loss. Working it into our evaluation method for RpsElement:

        if(o_element.name in self.win):
            return rpsc.GAME_CONDITION_WIN
        elif(o_element.name == self.name):
            return rpsc.GAME_CONDITION_DRAW
        else:
            return rpsc.GAME_CONDITION_LOSE

So now, we only need to remove it from all our JSON-configurations (although leaving it in won't technically crash the script) as well as remove it from the RpsElement class and its functions.

Error Handling

So far we've used some checks every now and then but there's not really any real error handling. If the application doesn't crash completely, the player will never know that something went wrong.

Let's fix that using Tkinter Messagebox:

        game.filename = file + "_bak"
        try:
            game.load()
            # refresh GUI possibilities
            box.configure(values=game.elements)
        except Exception as err:
            messagebox.showerror(title=rpsc.GUI_ERROR_TITLE, message=err)

To test this, I added the line game.filename = file + "_bak" which never resolves to any real file, so there should be an exception .. but there wasn't. Turns out we used try/except already in our loading and saving functions for the RpsGame-class.

I removed them from the class since I want the error handling to happen within the GUI and in the main script. Trying to load a JSON will result in this messagebox:

Bildschirmfoto 2021-12-17 um 16.41.33.png

GitHub: project files and .gitignore

GitHub is great for sharing projects and code. Even if it's just meant as an example, some code for a tutorial or some snippets (see Gist). This won't be a full on GitHub or even git tutorial, I just want to briefly mention what usually should go into a .gitignore-file for Python.

Basically, it's a file with patterns that should be excluded when you push an update to GitHub.

You can find a really comprehensive one here. For our project, it's fine to investigate our project directory and since I don't plan on deploying or packaging the application, that will do.

Bildschirmfoto 2021-12-17 um 17.09.49.png

There's really only the pycache-folder, so I'll create a .gitignore with only the relevant files:

__pycache__/
*.pyc

Finally, the entire code

Finally, I'm not going to put the entire code on hashnode in code blocks, it's available here on GitHub.