Quantcast
Channel: Planet Python
Viewing all articles
Browse latest Browse all 22463

The Python Coding Blog: Simulating a Tennis Match Using Object-Oriented Programming in Python—Wimbledon Special Part 1

$
0
0

With Wimbledon underway, I thought of paying homage to the classic tennis tournament with a program simulating a tennis match in Python. I’ll use this program to explore several key concepts in Object-Oriented Programming.

You’ll write a program which will allow you to do two things:

  • Part 1: You can keep the score of a live match by logging who wins each point and letting the program sort out the score
  • Part 2: You can simulate a tennis match point-by-point for players with different ranking points

This article covers Part 1. A separate, shorter article will deal with Part 2.

The key Python topic you’ll explore in this article is Object-Oriented Programming. You’ll learn about:

  • Creating classes in Python using the class keyword
  • Initialising objects using __init__()
  • Defining methods in a class
  • Creating string representations for the class using __str__() and __repr__()
  • Creating classes using inheritance

You don’t need to be familiar with object-oriented programming concepts to follow this tutorial. I’ll assume you’re familiar with Python’s built-in data types and defining functions with input parameters, including parameters with default values.

You can read more about functions in the chapter Functions Revisited in The Python Coding Book. There’s also a chapter on Object-Oriented Programming, which covers the basics of the topic. This tutorial will summarise the key points from that chapter and build on them.

A second article will follow this one in which the code from this article will be used to run simulations of thousands of tennis matches. These simulations will explore how various parameters affect the results of tennis matches.

How to Read This Article

I wrote this article in a way that you can easily choose how to consume its contents. For example, if you’re very familiar with the tennis scoring system, you can skip the next section.

If you’re already familiar with the basics of object-oriented programming and know how to create classes and instances, you can skim through the Object-Oriented Programming section.

You should be able to jump easily from one section to the next and still follow the article if you wish. But if you’re new to object-oriented programming in Python (and to tennis), then you can sit back and enjoy the full article…

The Tennis Scoring System

Tennis is not the most straightforward sport when it comes to scoring. If you’re already familiar with scoring in tennis, you can safely skip this section.

I’ll keep this summary of the tennis scoring system brief.

Match

A tennis match consists of a number of sets. Tennis matches are either best-of-three or best-of-five sets. A tennis match ends when a player wins two sets in a best-of-three match or three sets in a best-of-five match.

Set

Each set consists of several games. The target to win a set is six games. However, it’s not quite as straightforward:

  • If a player reaches six games and the opponent only has four or fewer games, the player with six games wins the set. The scores can therefore be 6-0, 6-1, 6-2, 6-3, or 6-4 under this section of the rules.
  • If both players have won five games each, then there are two options:
    • Either a player reaches seven games while the other is still on five. The player who reaches seven wins the set with a score of 7-5
    • If both players reach six games each, they play a special game called a tiebreak. There are slight variations on when and how the tiebreak is played. However, in this tutorial, I’ll assume that all sets that reach 6-6 will be settled using a tiebreak. When a player wins a set by winning the tiebreak, the set’s score is 7-6. This is the only time when there’s only one game difference between the set’s winner and loser.

Game

The player who starts the point is the server since the first delivery in a point is the serve. The same player serves throughout the entire game. The other player will then serve the following game, and they’ll keep alternating throughout the match.

Each game consists of several points. The first point a player wins is registered as “15” instead of 1 point. The second point is “30”, and the third point is “40”.

In each game, the server’s points are called out first. Therefore, 30-0 means that the server won two points and the receiver—the other player—hasn’t won any points in this game yet. However, 0-30 means that the server hasn’t won any points and the receiver won two points.

Incidentally, the “0” is not referred to as zero but as “love” in tennis scoring. Therefore a score of 30-0 is called out as thirty-love.

If a player is on “40” and wins the next point, they win the game as long as the other player isn’t also on “40”. Therefore, if the score is 40-0, 40-15, or 40-30, the server will win the game if he or she wins the next point. If the score is 0-40, 15-40, or 30-40, the receiver will win the game if he or she wins the next point.

If the score is 40-40, then a player needs to win two successive points to win the game. Incidentally, just to keep you on your toes, 40-40 is called “deuce” and not forty-all!

The player who wins the next point at “40-40” has an “advantage”, and the score is either 40-Ad or Ad-40. If the player with the advantage wins the next point, they win the game.

Tiebreak

We’re almost there. You read earlier that when a set is tied 6-6, a special type of game is played. This is a tiebreak. The points in a tiebreak are scored as 1, 2, 3, and so on. The first person to reach 7 points wins as long as the other player has 5 or fewer points.

If the players are tied on 6 points each in the tiebreak, they’ll keep playing until one player has a two-point advantage.

Writing A Program To Score A Tennis Match In Python

The primary aim of this program is to keep track of the score of a tennis match, point by point. You’ll be able to select who won a point and the program will update the score. The program will show when a game is won, when a set is won, and when the match is won.

The program will also keep a record of the entire match, point by point.

In Part 2, you’ll modify this code to create a simulation of a tennis match by assigning points randomly following specific rules.

In the next section, you’ll read how you’ll use the key concepts in object-oriented programming in Python to plan and write the program for scoring and simulating a tennis match.

Object-Oriented Programming

The simplest way to describe what a computer program does is the following:

  • it stores data
  • it does stuff with the data

Typically, you create data structures to store data, and you use functions to perform actions on the data. In object-oriented programming, you create objects that contain both the data and the tools to do stuff with that data within them.

You’re already very familiar with this concept, even if you don’t know it yet. Let’s assume you create the following string and list:

>>> title = "The Python Coding Book">>> contents = ["Intro", "Chapter 1", "Chapter 2"]

>>> type(title)
<class 'str'>>>> type(contents)
<class 'list'>>>> title.upper()
'THE PYTHON CODING BOOK'

>>> contents.append("Chapter 3")
>>> contents
['Intro', 'Chapter 1', 'Chapter 2', 'Chapter 3']

You can see that Python describes these as classes when you ask for the type of the objects. The object of type str has methods such as upper()attached to it. These are the actions that you can perform on data of type str.

However, lists have a different set of methods. In this case, you use append(), which is a method in the list class.

When you define your own classes, you’re creating a template showing what data you want your objects to have and what you’d like to do with the data.

This will start to make more sense as we look at the examples from the tennis project.

What classes do you need to simulate a tennis match in Python?

One way of looking at object-oriented programming is to think of the problem more from a human being’s point of view instead of trying to modify your planning to suit the computer. What do I mean by this?

Let’s take the tennis example you’re working on. The task is to keep track of the score during a tennis match. You’d like the computer program to do the hard work.

When using an object-oriented mindset, you want to start with the components of the problem that any human being will easily recognise.

You could start with the players and the match in this case. There are certain attributes that each player must have, for example, a name and the number of ranking points. These are the data you need for the player. You also want to be able to update a player’s ranking points.

There are attributes each match has, too. Each match needs to have two players, for example. And each match can be best-of-three sets or best-of-five. You also want to be able to play a match, so play_match() may be a useful function to have linked to each match.

Creating the classes

You can start creating these classes in a file called tennis.py. If you’re not familiar with classes, you’ll find some of the syntax a bit weird initially. However, you’ll read about what everything stands for in the coming paragraphs:

# tennis.py

class Player:
    def __init__(self, name="", ranking_points=0):
        self.name = name
        self.ranking_points = ranking_points

class Match:
    def __init__(
        self,
        player_1=Player(),
        player_2=Player(),
        best_of_5=True,
    ):
        self.players = (player_1, player_2)
        self.best_of_5 = best_of_5
        self.sets_to_play = 5 if best_of_5 else 3

You define a class using the class keyword followed by the name you choose for your class. By convention, class names are capitalised using the UpperCamelCase format.

The first method you define in each class is the initialisation method __init__(). This is a special method, as shown by the leading and trailing double underscores. Often, such methods are called dunder methods because of these double underscores.

When you create an object, the __init__() method is called. Therefore, you can use this method to set up the object. The best way to see what’s happening is by creating some objects using these classes. You’ll do this in the next section.

Testing your classes

You’re defining the classes in tennis.py. You could add code at the end of this script to test the classes. However, it’s often better to create a new script for this. You can call this script play_tennis.py, which you’ll use to score matches and simulate matches later:

# play_tennis.py

from tennis import Player, Match

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

print(nadal.name)
print(nadal.ranking_points)
print(djokovic.name)

This gives the following output:

Rafael Nadal
2000
Novak Djokovic

You start by importing the classes Player and Match from the tennis module, which is the script tennis.py.

You create an instance of a class by using the class name followed by parentheses (). You also include two arguments for each Player instance you create. These arguments are linked to the second and third parameter names in the __init__() method, name and ranking_points.

When you defined __init__() for the Player class, you included default values for name and ranking_points. Therefore, you can create an instance of Player simply by calling Player() with no arguments. This will create a player with no name (empty string) and with 0 ranking points.

What about the first parameter, self?

What about self?

You may have noticed the name self appears several times in the class definitions. Depending on what editor or IDE you’re using to code, you may have also noticed that your IDE auto-filled some of them and colour-coded them differently to other names.

A class is a template for creating objects that share similar attributes. When you define a class, you’re not creating any objects yet. This happens when you create an instance of the class. In the example above, when you created two instances of the Player class you assigned them to the variable names nadal and djokovic. However, when you defined the class, you didn’t have variable names yet as you hadn’t created any instances at that point.

The name self is a placeholder for the name of the object you’ll use later on. It’s a dummy variable name referring to the object itself which you’ll create later.

Therefore, when you define self.name in the Player class’s __init__() method, you’re creating an attribute called name that’s attached to the object itself. However, the object doesn’t exist yet. When you create these objects in play_tennis.py, you can use the actual variable name instead of self. So, instead of writing self.name, you can write nadal.name or djokovic.name.

self is also the first parameter in __init__()‘s signature. You’ll see that this is the case for other methods defined in a class, too. This means that when you use a method, the object itself is always passed as an argument to the method. You’ll look at this point later on.

Defining methods

You can add a method to the Player class which allows you to update the ranking points for a player:

# tennis.py

class Player:
    def __init__(self, name="", ranking_points=0):
        self.name = name
        self.ranking_points = ranking_points

    def update_ranking_points(self, points_change):
        self.ranking_points += points_change

class Match:
    def __init__(
        self,
        player_1=Player(),
        player_2=Player(),
        best_of_5=True,
    ):
        self.players = (player_1, player_2)
        self.best_of_5 = best_of_5
        self.sets_to_play = 5 if best_of_5 else 3

You define a method in the Player class which you call update_ranking_points(). The first parameter is self, which means the object itself will be passed to the function. You also add the parameter points_change, and you use this to increment the value of self.ranking_points.

You can test this method in play_tennis.py:

— 8,10,12-13 —

# play_tennis.py

from tennis import Player, Match

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

# print(nadal.name)
print(nadal.ranking_points)
# print(djokovic.name)

nadal.update_ranking_points(125)
print(nadal.ranking_points)

The output now shows that the ranking points increased from their original value of 2000 to 2125 once you call nadal.update_ranking_points(125):

2000
2125

This call only affects the ranking points for this player. Because the method is attached to the object, you can safely assume it will only affect that object.

Starting To Code The Tennis Scoring Rules

You’re ready to start writing the code to keep track of points in the match. But, before doing so, you can create a couple of new classes. Player and Match aren’t the only entities that matter to us. Each match contains a number of sets, and each set consists of a number of games. Since sets will have similar attributes, you can create a class for them. And you can do the same for games:

# tennis.py

class Player:
    def __init__(self, name="", ranking_points=0):
        self.name = name
        self.ranking_points = ranking_points

    def update_ranking_points(self, points_change):
        self.ranking_points += points_change

class Match:
    def __init__(
        self,
        player_1=Player(),
        player_2=Player(),
        best_of_5=True,
    ):
        self.players = (player_1, player_2)
        self.best_of_5 = best_of_5
        self.sets_to_play = 5 if best_of_5 else 3

class Set:
    def __init__(self, match: Match, set_number=0):
        self.match = match
        self.set_number = set_number

class Game:
    def __init__(self, set: Set, game_number=0):
        self.set = set
        self.game_number = game_number

Since each set is part of a match, you can link a Set object to a Match object when creating a Set. You achieve this by adding the Match object as an argument for the set’s initialisation method. You also create a set_number attribute to keep track of which set within a match you’re dealing with.

The same applies to games which are always part of a set. You use type hinting to show that the match parameter refers to a Match object in Set.__init__() and that the set parameter refers to a Set object in Game.__init__(). It’s not necessary to use type hinting. The main reason I’m using them in this case is that if you’re using an IDE that makes use of these, it makes writing the code easier. Your IDE will be able to offer autocompletion and other checks.

Note that the parameter names are written in lower case, match and set, whereas the class names are in uppercase Match and Set. The naming convention makes it easier to know what you’re referring to in your code.

Refactoring

As you write this code, you’ll be making changes to aspects of the code you’ve already written. I could guide you through the final code step by step. However, that’s not how anyone writes code. The process of writing a program almost always requires refactoring. Refactoring is the process of changing your program’s design without changing what it does. As you write more of your program, you’ll start to realise that you can do things differently.

Sometimes, refactoring is as simple as changing the name of a variable to make your code neater and more readable. Sometimes, it means making more significant changes.

Later, you’ll create yet another class, and you’ll need to make changes to the classes you’ve already written.

Scoring Points In A Game

You’ll start working on the manual version of scoring points. In this version, the program’s user will select one of the two players at the end of every point to indicate who won the point. The code will work out the score.

Therefore, you’ll need a method called score_point() in the Game class. A player can only score points in games, so this class is the only one that needs this method.

Let’s see what else you need to store in each instance of Game:

  • You need to have access to information about the players. Since the Game is linked to a Set and the Set is linked to a Match, you can always access the players’ information by using self.set.match.players in Game. This refers to the tuple containing the two Player objects. However, it’s easier to create a new reference pointing to the players within Game:
    self.players = self.set.match.players
    You could think ahead and plan to do the same in the Set class. Therefore, you would only need to access self.set.players in that case. But I won’t make that leap yet
  • You need to keep track of each player’s points in the game. There are several options for this. In this program, you’ll use a dictionary where the key is a Player object, and the value is the score for that player
  • You can also create a winner attribute to store the winner of the game
  • The Game class also needs access to the strange system of points in tennis games

You can add these attributes and start writing score_point():

# tennis.py

class Player:
    def __init__(self, name="", ranking_points=0):
        self.name = name
        self.ranking_points = ranking_points

    def update_ranking_points(self, points_change):
        self.ranking_points += points_change

class Match:
    def __init__(
        self,
        player_1=Player(),
        player_2=Player(),
        best_of_5=True,
    ):
        self.players = (player_1, player_2)
        self.best_of_5 = best_of_5
        self.sets_to_play = 5 if best_of_5 else 3

class Set:
    def __init__(self, match: Match, set_number=0):
        self.match = match
        self.set_number = set_number

class Game:
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):
        self.set = set
        self.game_number = game_number
        self.players = self.set.match.players
        self.score = {
            self.players[0]: 0,  # The key is of type Player
            self.players[1]: 0,
        }

    def score_point(self, player: Player):
        current_point = self.score[player]
        self.score[player] = Game.points[
            Game.points.index(current_point) + 1
        ]

You define a class attribute called points. This is not specific to each instance of the class, but it’s common to all class instances. The points used to score a game are the same for every game. You can access this class attribute when you need it in the class definition using Game.points.

What about the latter parts of a game?

The algorithm in score_point() still needs lots of work. At the moment, the method will assign the next item in Game.points as a value for the player’s score. For example, if the player is currently on “15”, then current_point will be 15 and Game.points.index(current_point) returns 1, which is the index corresponding to 15 in the tuple Game.points. You add 1 to this index to access the next item in the tuple.

This works fine in the early parts of a game. However, if you remember the scoring rules, things can get a bit more complex in the latter parts of a game.

You can test this version first by updating play_tennis.py:

# play_tennis.py

from tennis import Player, Match, Set, Game

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)
test_set = Set(test_match)
test_game = Game(test_set)

print(test_game.score)
test_game.score_point(nadal)
print(test_game.score)

test_game.score_point(nadal)
print(test_game.score)

test_game.score_point(djokovic)
print(test_game.score)

You create a Match, Set, and Game instance and show the score before and after several score_points() calls. This gives the output:

{<tennis.Player object at 0x10b897eb0>: 0, <tennis.Player object at 0x10b897e50>: 0}
{<tennis.Player object at 0x10b897eb0>: 15, <tennis.Player object at 0x10b897e50>: 0}
{<tennis.Player object at 0x10b897eb0>: 30, <tennis.Player object at 0x10b897e50>: 0}
{<tennis.Player object at 0x10b897eb0>: 30, <tennis.Player object at 0x10b897e50>: 15}

If you look closely, you’ll see the game points for each player on each line. The score correctly changes from 0-0 to 15-0, 30-0 and then 30-15. So far, score_points() is working for the early parts of a game.

There is one other issue we may want to fix. When you print test_game.score, the dictionary values show the score as expected—0, 15, 30 and so on. However, the keys show a rather obscure printout.

The keys in the score dictionary are objects of type Player. The representation of these objects shows that these are tennis.Player objects, and it also shows the unique id for the objects. This is not very instructive. Later in this article, you’ll read about the options you have to change how the object is represented when you print it.

Dealing with game scores in the latter parts of the game

Let’s recap what the possible outcomes are towards the end of a game:

  • If a player who’s on “40” wins the point and the other player’s score is not “40” or “Ad”, then the player who won the point wins the game
  • If a player who’s on “Ad” wins the point, then he or she wins the game
  • If both players are on “40”, the player who wins the point moves to “Ad”
  • If a player who’s on “40” wins the point and the other player is on “Ad”, both players return to “40”

You can update score_point() to reflect these options. Note that I’m truncating sections of the code that haven’t changed for display purposes. I’m using ellipsis (...) to show truncated classes or functions. This is similar to how some IDEs display collapsed code blocks to avoid lots of vertical scrolling:

# tennis.py

class Player:...

class Match:...

class Set:...

class Game:
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):
        self.set = set
        self.game_number = game_number
        self.players = self.set.match.players
        self.score = {
            self.players[0]: 0,  # The key is of type Player
            self.players[1]: 0,
        }

    def score_point(self, player: Player):
        current_point = self.score[player]
        # Player who wins point was on 40
        if self.score[player] == 40:
            # Other player is on Ad
            if "Ad" in self.score.values():
                # Update both players' scores to 40
                for each_player in self.players:
                    self.score[each_player] = 40
            # Other player is also on 40 (deuce)
            elif list(self.score.values()) == [40, 40]:
                # Point winner goes to Ad
                self.score[player] = "Ad"
            # Other player is on 0, 15, or 30
            else:
                # player wins the game
                self.score[player] = "Game"
        # Player who wins point was on Ad
        elif self.score[player] == "Ad":
            # player wins the game
            self.score[player] = "Game"
        # Player who wins point is on 0, 15, or 30
        else:
            self.score[player] = Game.points[
                Game.points.index(current_point) + 1
            ]

You include all possible options in score_point(). When the player wins the game, his or her score changes to “Game” to show the final game score.

You can test this code by manually calling score_point() several times for different players in play_tennis.py. You’ll need to test all possible outcomes to ensure everything works as you expect. Here’s one version testing several outcomes:

# play_tennis.py

from tennis import Player, Match, Set, Game

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)
test_set = Set(test_match)
test_game = Game(test_set)

print(test_game.score)
test_game.score_point(nadal)
print(test_game.score)

test_game.score_point(nadal)
print(test_game.score)

test_game.score_point(djokovic)
print(test_game.score)

test_game.score_point(djokovic)
print(test_game.score)

test_game.score_point(djokovic)
print(test_game.score)

test_game.score_point(nadal)
print(test_game.score)

test_game.score_point(nadal)
print(test_game.score)

test_game.score_point(djokovic)
print(test_game.score)

test_game.score_point(djokovic)
print(test_game.score)

test_game.score_point(nadal)
print(test_game.score)

test_game.score_point(nadal)
print(test_game.score)

test_game.score_point(nadal)
print(test_game.score)

The output from this code is:

{<tennis.Player object at 0x10b52feb0>: 0, <tennis.Player object at 0x10b52fe50>: 0}
{<tennis.Player object at 0x10b52feb0>: 15, <tennis.Player object at 0x10b52fe50>: 0}
{<tennis.Player object at 0x10b52feb0>: 30, <tennis.Player object at 0x10b52fe50>: 0}
{<tennis.Player object at 0x10b52feb0>: 30, <tennis.Player object at 0x10b52fe50>: 15}
{<tennis.Player object at 0x10b52feb0>: 30, <tennis.Player object at 0x10b52fe50>: 30}
{<tennis.Player object at 0x10b52feb0>: 30, <tennis.Player object at 0x10b52fe50>: 40}
{<tennis.Player object at 0x10b52feb0>: 40, <tennis.Player object at 0x10b52fe50>: 40}
{<tennis.Player object at 0x10b52feb0>: 'Ad', <tennis.Player object at 0x10b52fe50>: 40}
{<tennis.Player object at 0x10b52feb0>: 40, <tennis.Player object at 0x10b52fe50>: 40}
{<tennis.Player object at 0x10b52feb0>: 40, <tennis.Player object at 0x10b52fe50>: 'Ad'}
{<tennis.Player object at 0x10b52feb0>: 40, <tennis.Player object at 0x10b52fe50>: 40}
{<tennis.Player object at 0x10b52feb0>: 'Ad', <tennis.Player object at 0x10b52fe50>: 40}
{<tennis.Player object at 0x10b52feb0>: 'Game', <tennis.Player object at 0x10b52fe50>: 40}

This verifies several scenarios, but not all of them. I’ll leave it as an exercise for you to test the other options.

Tidying up score_point() in the Game class

You can add a new attribute to the Game class to store the winner of the game and assign the Player object corresponding to the winner to this new attribute when the game ends. Then, you can also use this winner attribute to ensure that score_point() can’t be used when a game has already ended.

You may have noticed that there are two parts in the algorithm corresponding to the player winning the game. And you’re about to add another line to each of these cases. You need to store the winning Player in an attribute named winner. Since we like to avoid repetition, you can add a Boolean flag to determine when a player wins the game:

# tennis.py

class Player:...

class Match:...

class Set:...

class Game:
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):
        self.set = set
        self.game_number = game_number
        self.players = self.set.match.players
        self.score = {
            self.players[0]: 0,  # The key is of type Player
            self.players[1]: 0,
        }
        self.winner = None

    def score_point(self, player: Player):
        if self.winner:
            print(
              "Error: You tried to add a point to a completed game"
            )
            return
        game_won = False
        current_point = self.score[player]
        # Player who wins point was on 40
        if self.score[player] == 40:
            # Other player is on Ad
            if "Ad" in self.score.values():
                # Update both players' scores to 40
                for each_player in self.players:
                    self.score[each_player] = 40
            # Other player is also on 40 (deuce)
            elif list(self.score.values()) == [40, 40]:
                # Point winner goes to Ad
                self.score[player] = "Ad"
            # Other player is on 0, 15, or 30
            else:
                # player wins the game
                game_won = True
        # Player who wins point was on Ad
        elif self.score[player] == "Ad":
            # player wins the game
            game_won = True
        # Player who wins point is on 0, 15, or 30
        else:
            self.score[player] = Game.points[
                Game.points.index(current_point) + 1
            ]

        if game_won:
            self.score[player] = "Game"
            self.winner = player

String Representation Of Objects

Before you move on to writing the Set and Match classes, let’s get back to an issue you’ve encountered earlier.

Try and print out the values of objects you create:

# play_tennis.py

from tennis import Player, Match, Set, Game

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)
test_set = Set(test_match)
test_game = Game(test_set)

print(nadal)
print(test_game)

The output from this code is:

<tennis.Player object at 0x10d07beb0><tennis.Game object at 0x10d07b3a0>

These are the representations of the objects you’ve seen earlier. They’re not very informative. However, you can change how objects are represented when you print them out.

The __str__() dunder method

You can add another dunder method called __str__() to the class definitions, which defines a string representation for the object. Once again, I’m truncating parts of the code in the display below:

# tennis.py

class Player:
    def __init__(self, name="", ranking_points=0):...

    def update_ranking_points(self, points_change):...

    def __str__(self):
        return self.name

class Match:...

class Set:...

class Game:
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):...

    def score_point(self, player: Player):...

    def __str__(self):
        score_values = list(self.score.values())
        return f"{score_values[0]} - {score_values[1]}"

The __str__() method is called when a user-friendly string representation is needed, such as when you use print(). You choose to display only the player’s name when you print out Player. In the Game class, you choose to show the score when printing the object.

You can run the script in play_tennis.py again, and the output will now be:

Rafael Nadal
0 - 0

This is great. But let’s return to printing the dictionary containing the score, as you did earlier:

# play_tennis.py

from tennis import Player, Match, Set, Game

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)
test_set = Set(test_match)
test_game = Game(test_set)

test_game.score_point(nadal)
print(test_game.score)

The output is:

{<tennis.Player object at 0x108e1feb0>: 15, <tennis.Player object at 0x108e1fe50>: 0}

The code still displays the somewhat obscure representation despite the fact you defined __str__() for the Player class.

The __repr__() dunder method

The reason is that there are two kinds of string representations. The one you’ve taken care of is the user-friendly one. It’s meant for the user of a program. This string representation should show information the user will find relevant, such as the player’s name and the game score.

You sometimes want a string representation that’s meant for the programmer rather than the user. This should have information relevant to a Python-literate programmer. To define this, you need another dunder method called __repr__():

# tennis.py

class Player:
    def __init__(self, name="", ranking_points=0):...

    def update_ranking_points(self, points_change):...

    def __str__(self):
        return self.name

    def __repr__(self):
        return (
            f"Player(name='{self.name}', "
            f"ranking_points={self.ranking_points})"
        )

class Match:...

class Set:...

class Game:
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):...

    def score_point(self, player: Player):...

    def __str__(self):
        score_values = list(self.score.values())
        return f"{score_values[0]} - {score_values[1]}"

    def __repr__(self):
        return (
            f"Game(set={self.set!r}, "
            f"game_number={self.game_number})"
        )

Well done if you spotted the !r in Game.__repr__(). We’ll get back to this very soon.

When you run play_tennis.py now, the output shows the string representations returned by __repr__() when you print the dictionary:

{Player(name='Rafael Nadal', ranking_points=2000): 15, Player(name='Novak Djokovic', ranking_points=2000): 0}

You can use Python’s built-in repr() to return this string representation:

# play_tennis.py

from tennis import Player, Match, Set, Game

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)
test_set = Set(test_match)
test_game = Game(test_set)

test_game.score_point(nadal)

print(test_game)
print(repr(test_game))

print(test_game) displays the string returned by __str__() whereas print(repr(test_game)) shows the representation from the __repr__() dunder method:

15 - 0
Game(set=<tennis.Set object at 0x10d567430>, game_number=0)

Note that the Set object is still displayed using the default representation since you haven’t defined the string representation dunder methods for Set yet.

When you use f-strings, the string from __str__() is used by default. However, you can replace this with the string from __repr__ by adding a !r in the f-string:

# play_tennis.py

from tennis import Player, Match, Set, Game

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)
test_set = Set(test_match)
test_game = Game(test_set)

print(f"{nadal}")
print(f"{nadal!r}")

The output from the code shows that the !r forces the __repr__() string representation to be used:

Rafael Nadal
Player(name='Rafael Nadal', ranking_points=2000)

The __str__() representation is meant to be user-friendly while the __repr__() representation is aimed to be informative for a programmer. Often, the __repr__() dunder method returns a string which can be used to recreate the object. You can see that this is the case for the string returned by Player.__repr__() which represents valid Python code to create the object.

Planning The Set Class

You can now shift your attention to the Set class. You’ve already created the match and set_number attributes.

A Set object will also need:

  • A reference to the players
  • An attribute to keep the score in the set. This can be a dictionary just like the one you used in Game
  • An attribute to store the winner of the set once the set is complete
  • A list containing references to all the games in the set

The first three of these are attributes that are common with the Game class, too. Both classes need a players attribute, a score attribute, and a winner attribute.

You can also plan ahead, and you’ll realise that the Match class also needs the same three attributes.

We don’t like repetition in programming, and we want to be efficient by reusing code as much as possible. Therefore, you can refactor your code and extract elements common to all three and place them in a separate class.

You can start by defining this new class that sits above Match, Set, and Game. You can name this class using a generic name such as Unit:

# tennis.py

class Player:...

class Unit:
    def __init__(self, players=(Player(), Player())):
        self.players = players
        self.score = {
            self.players[0]: 0,  # The key is of type Player
            self.players[1]: 0,
        }
        self.winner = None

    def get_winner(self):
        return self.winner

    def get_score(self):
        return self.score

    def is_running(self):
        return self.winner == None

class Match:...

class Set:...

class Game:...

The __init__() method in the Unit class contains attributes that Match, Set, and Game all require. Note that none of the code in __init__() is new. It’s code you wrote elsewhere already.

You also define three methods that will be useful for all the classes. get_winner() and get_score() return the value of the attributes self.winner and self.score. These functions are not necessary, but it’s good practice to have getter methods to get the values of attributes.

is_running() returns a Boolean value to indicate whether that unit of the game is still running.

Before working on the Set class, you can return to the Game class and refactor your code to use the new Unit class.

Inheritance

This leads us to inheritance. You can create a class which inherits the attributes and methods from another class. All the attributes and methods in the parent class will also be present in the child class. Then, you can add more attributes and methods to the new class to make it more specific to your needs.

Game can inherit all the attributes and methods from Unit. Therefore, you no longer need to define these in Game. You can change Game into a class which inherits from Unit:

# tennis.py

class Player:...

class Unit:
    def __init__(self, players=(Player(), Player())):
        self.players = players
        self.score = {
            self.players[0]: 0,  # The key is of type Player
            self.players[1]: 0,
        }
        self.winner = None

    def get_winner(self):
        return self.winner

    def get_score(self):
        return self.score

    def is_running(self):
        return self.winner == None

class Match:...

class Set:...

class Game(Unit):
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):
        super().__init__(set.match.players)
        self.set = set
        self.game_number = game_number

    def score_point(self, player: Player):...

    def __str__(self):...

    def __repr__(self):...

You show that Game inherits from Unit when you define the class:

class Game(Unit):

If you compare the __init__() method in Game to the one you wrote earlier, you’ll notice that the definitions of the players, score, and winner attributes are missing. However, you add a call to super().__init__().

super() gives you access to the methods in the superclass, or parent class. Therefore, when you initialise Game, you’re also initialising Unit. Since super().__init__() calls the initialisation method for Unit, you need to pass the arguments needed by Unit.

You can access the tuple containing the players via set.match.players. In reality, when writing this code, you can look ahead and realise that Set will also inherit from Unit. Therefore, it will also have a players attribute. You’ll be able to use set.players instead. However, let’s take this one step at a time. You’ll return to this line and refactor it later once you’ve completed the Set class.

Game now has access to the attributes and methods in Unit and the additional ones you define within Game. You can test this in play_tennis.py:

# play_tennis.py

from tennis import Player, Match, Set, Game

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)
test_set = Set(test_match)
test_game = Game(test_set)

test_game.score_point(nadal)

print(test_game.players)
print(test_game.is_running())
print(test_game.get_winner())

You don’t import Unit in this script. However, Game inherits from it. Therefore, test_game has the attribute players and the methods is_running() and get_winner(). This script gives the following output:

(Player(name='Rafael Nadal', ranking_points=2000), Player(name='Novak Djokovic', ranking_points=2000))
True
None

As the game is still in progress—there has only been one point played—is_running() returns True and get_winner() returns None.

You can try to comment out the line with super().__init__() in the class definition and re-run the script to see what happens.

Completing The Set Class

Now, you can shift your attention to writing the Set class you planned earlier. Set will also inherit from Unit, and it will also have a games attribute to store all the games played within the set:

# tennis.py

class Player:...

class Unit:
    def __init__(self, players=(Player(), Player())):
        self.players = players
        self.score = {
            self.players[0]: 0,  # The key is of type Player
            self.players[1]: 0,
        }
        self.winner = None

    def get_winner(self):
        return self.winner

    def get_score(self):
        return self.score

    def is_running(self):
        return self.winner == None

class Match:...

class Set(Unit):
    def __init__(self, match: Match, set_number=0):
        super().__init__(match.players)
        self.match = match
        self.set_number = set_number
        self.games = []

class Game(Unit):
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):
        super().__init__(set.players)
        self.set = set
        self.game_number = game_number

    def score_point(self, player: Player):...

    def __str__(self):...

    def __repr__(self):...

Once you write Set.__init__(), including the call to super().__init__(), you can also return to Game and refactor the argument in its super().__init__(). Instead of using set.match.players you can use set.players. You don’t need to do this, but it’s neater this way!

Play a game in the set

Next, you need to be able to play games within a set. Therefore, you can create a method in Set called play_game():

# tennis.py

class Player:...

class Unit:...

class Match:...

class Set(Unit):
    def __init__(self, match: Match, set_number=0):
        super().__init__(match.players)
        self.match = match
        self.set_number = set_number
        self.games = []

    def play_game(self):
        # Creat a Game object and append to .games list
        game = Game(self, len(self.games) + 1)
        self.games.append(game)

        # Ask for user input to record who won point
        print(
            f"\nRecord point winner: "
            f"Press 1 for {self.players[0]} | "
            f"Press 2 for {self.players[1]}"
        )
        while game.is_running():
            point_winner_idx = (
                int(input("\nPoint Winner (1 or 2) ->")) - 1
            )
            game.score_point(self.players[point_winner_idx])
            print(game)

        # Game over - update set score
        self.score[game.winner] += 1
        print(f"\nGame {game.winner.name}")
        print(f"\nCurrent score: {self}")

class Game(Unit):
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):...

    def score_point(self, player: Player):...

    def __str__(self):...

    def __repr__(self):...

The play_game() method does the following:

  1. Creates a Game object and appends it to the games attribute for the Set object
  2. Asks the user to record which player won the point and converts to zero-index by subtracting 1. You can add some code to check that the input is 1 or 2 if you wish.
  3. Calls game.score_point()
  4. Prints the game score, which is defined by Game.__str__()
  5. Repeats steps 2-4 until the game ends
  6. Determine and store the game-winner
  7. Update the score in the set by adding 1 to the winning player’s current score
  8. Print the game-winner and the current set score

You can now play an entire game of a set by calling play_game() on a Set object:

# play_tennis.py

from tennis import Player, Match, Set

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)
test_set = Set(test_match)

test_set.play_game()

You no longer need to create a Game object as this is created within test_set.play_game(). Therefore, you longer need to import Game, either.

You’ll be able to record all the points in a game when you run this script:

Record point winner: Press 1 for Rafael Nadal | Press 2 for Novak Djokovic

Point Winner (1 or 2) -> 1
15 - 0

Point Winner (1 or 2) -> 2
15 - 15

Point Winner (1 or 2) -> 1
30 - 15

Point Winner (1 or 2) -> 1
40 - 15

Point Winner (1 or 2) -> 1
Game - 15

Game Rafael Nadal

Current score: <tennis.Set object at 0x10ac6faf0>

This works as expected. However, the current set score is not displayed in the final line. This happens because you haven’t yet defined __str__() for the Set class. Let’s sort this out now and also define __repr__():

# tennis.py

class Player:...

class Unit:...

class Match:...

class Set(Unit):
    def __init__(self, match: Match, set_number=0):
        super().__init__(match.players)
        self.match = match
        self.set_number = set_number
        self.games = []

    def play_game(self):
        # Creat a Game object and append to .games list
        game = Game(self, len(self.games) + 1)
        self.games.append(game)

        # Ask for user input to record who won point
        print(
            f"\nRecord point winner: "
            f"Press 1 for {self.players[0]} | "
            f"Press 2 for {self.players[1]}"
        )
        while game.is_running():
            point_winner_idx = (
                int(input("\nPoint Winner (1 or 2) ->")) - 1
            )
            game.score_point(self.players[point_winner_idx])
            print(game)

        # Game over - update set score
        self.score[game.winner] += 1
        print(f"\nGame {game.winner.name}")
        print(f"\nCurrent score: {self}")

    def __str__(self):
        return "-".join(
            [str(value) for value in self.score.values()]
        )

    def __repr__(self):
        return (
            f"Set(match={self.match!r}, "
            f"set_number={self.set_number})"
        )

class Game(Unit):...

When you run play_tennis.py now, the final line looks like this:

Current score: 1-0

You can test the __repr__() method too by adding print(repr(test_set)) in play_tennis.py.

Determine what stage in the set you’re at

The code you wrote so far works for the early stages of a set. The program adds each game a player wins to his or her set score. However, as you approach the end of a set, you’ll need to start looking out for different scenarios.

When a player reaches 6 games in a set, one of three things can happen:

  • If the other player has 4 or fewer games in the set, then the player who reached 6 wins the set. This accounts for the scores 6-4, 6-3, 6-2, 6-1, and 6-0
  • If the other player has 5 games in the set, and therefore, the score is currently 6-5, the set carries on as normal
  • If the other player also has 6 games in the set, then the current set score is 6-6 and the set moves to a tiebreak

You can code these rules in play_game():

# tennis.py

class Player:...

class Unit:...

class Match:...

class Set(Unit):
    def __init__(self, match: Match, set_number=0):
        super().__init__(match.players)
        self.match = match
        self.set_number = set_number
        self.games = []

    def play_game(self):
        # Creat a Game object and append to .games list
        game = Game(self, len(self.games) + 1)
        self.games.append(game)

        # Ask for user input to record who won point
        print(
            f"\nRecord point winner: "
            f"Press 1 for {self.players[0]} | "
            f"Press 2 for {self.players[1]}"
        )
        while game.is_running():
            point_winner_idx = (
                int(input("\nPoint Winner (1 or 2) ->")) - 1
            )
            game.score_point(self.players[point_winner_idx])
            print(game)

        # Game over - update set score
        self.score[game.winner] += 1
        print(f"\nGame {game.winner.name}")
        print(f"\nCurrent score: {self}")

        # Check stage within set
        # If it's an early stage of the set and no one
        # reached 6 or 7 games, there's nothing else to do
        # and method can return
        if (
            6 not in self.score.values()
            and 7 not in self.score.values()
        ):
            return
        # Rest deals with latter stages of set when at least
        # one player is on 6 games
        # Check for 6-6 score
        if list(self.score.values()) == [6, 6]:
            # ToDo: Deal with tiebreak scenario later
            ...
        # …7-5 or 7-6 score (if tiebreak was played, score
        # will be 7-6)
        for player in self.players:
            # player reaches 7 games
            if self.score[player] == 7:
                self.winner = player
                return
            # player reaches 6 games
            # and 6-6 and 7-6 already ruled out
            if self.score[player] == 6:
                # Exclude 6-5 scenario
                if 5 not in self.score.values():
                    self.winner = player

    def __str__(self):
        return "-".join(
            [str(value) for value in self.score.values()]
        )

    def __repr__(self):
        return (
            f"Set(match={self.match!r}, "
            f"set_number={self.set_number})"
        )

class Game(Unit):...

) def __repr__(self): return ( f”Set(match={self.match!r}, ” f”set_number={self.set_number})” ) class Game(Unit):…

The steps you take to check which stage of the set you’ve reached are:

  • If neither player’s number of games is 6 or 7, then the set just carries on, and you exit the method early using return
  • If both players have 6 games, then it’s a tiebreak. You left a to-do note in your code to get back to this later. Note that you also added an ellipsis (...) since you have to add at least one statement after an if statement
  • Next, you check if either player has reached 7 games. This means this player has won the set with a 7-5 or a 7-6 score. You’ll deal with 7-6 score later when you account for the tiebreak
  • If either player has 6 games, then the set is either on 6-5, and it should just carry on, or the player on 6 games won the set

You can test all these scenarios, except the tiebreak case, using play_tennis.py again:

# play_tennis.py

from tennis import Player, Match, Set

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)
test_set = Set(test_match)

while test_set.is_running():
    test_set.play_game()
    print(str(test_set))
print(test_set.winner)

You use a while loop to keep playing games until the set is over, showing the set score after each game and displaying the winner of the set at the end. I’ll leave this as an exercise for you to test all the options, except for the 6-6 scenario.

Adding The Tiebreak Option

A tiebreak is a type of game. However, it has different rules from normal games. Because it’s a game, a tiebreak will share many attributes with a standard game. Therefore, you can create a new class called Tiebreak which inherits from Game. However, you need score_point() to perform a different task to its counterpart in Game. You can do this by overriding the score_point() method:

# tennis.py

class Player:...

class Unit:...

class Match:...

class Set(Unit):...

class Game(Unit):
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):
        super().__init__(set.players)
        self.set = set
        self.game_number = game_number

    def score_point(self, player: Player):...

class Tiebreak(Game):
    def __init__(self, set: Set, game_number=0):
        super().__init__(set, game_number)

    def score_point(self, player: Player):
        if self.winner:
            print(
              "Error: You tried to add a point to a completed game"
            )
            return
        # Add point to player
        self.score[player] += 1
        # Tiebreak over only if player has 7 or more points
        # and there's at least a 2 point-gap
        if (
            self.score[player] >= 7
            and self.score[player] - min(self.score.values()) >= 2
        ):
            self.winner = player

The __init__() method calls super().__init__() and nothing else. This makes Tiebreak identical to Game. However, you redefine score_point() in Tiebreak. Since the method has the same name, it overrides the version in the parent class. Therefore Tiebreak behaves like Game except for score_point().

In score_point(), you’re using the tiebreak rules to add points and determine when the game ends.

Now, you can go back to Set.play_game() to complete this method. When you detect a tiebreak, you can recursively call self.play_game() again. However, you’ll need to ensure that a Tiebreak game is created rather than a standard Game.

You can do this by refactoring play_game() so that it takes an argument to determine whether it’s a tiebreak or a normal game:

# tennis.py

class Player:...

class Unit:...

class Match:...

class Set(Unit):
    def __init__(self, match: Match, set_number=0):
        super().__init__(match.players)
        self.match = match
        self.set_number = set_number
        self.games = []

    def play_game(self, tiebreak=False):
        # Creat a Game object and append to .games list
        if tiebreak:
            game = Tiebreak(self, len(self.games) + 1)
        else:
            game = Game(self, len(self.games) + 1)
        self.games.append(game)

        # Ask for user input to record who won point
        print(
            f"\nRecord point winner: "
            f"Press 1 for {self.players[0]} | "
            f"Press 2 for {self.players[1]}"
        )
        while game.is_running():
            point_winner_idx = (
                int(input("\nPoint Winner (1 or 2) ->")) - 1
            )
            game.score_point(self.players[point_winner_idx])
            print(game)

        # Game over - update set score
        self.score[game.winner] += 1
        print(f"\nGame {game.winner.name}")
        print(f"\nCurrent score: {self}")

        # Check stage within set
        # If it's an early stage of the set and no one
        # reached 6 or 7 games, there's nothing else to do
        # and method can return
        if (
            6 not in self.score.values()
            and 7 not in self.score.values()
        ):
            return
        # Rest deals with latter stages of set when at least
        # one player is on 6 games
        # Check for 6-6 score
        if list(self.score.values()) == [6, 6]:
            self.play_game(tiebreak=True)
            return
        # …7-5 or 7-6 score (if tiebreak was played, score
        # will be 7-6)
        for player in self.players:
            # player reaches 7 games
            if self.score[player] == 7:
                self.winner = player
                return
            # player reaches 6 games
            # and 6-6 and 7-6 already ruled out
            if self.score[player] == 6:
                # Exclude 6-5 scenario
                if 5 not in self.score.values():
                    self.winner = player

    def __str__(self):
        return "-".join(
            [str(value) for value in self.score.values()]
        )

    def __repr__(self):
        return (
            f"Set(match={self.match!r}, "
            f"set_number={self.set_number})"
        )

class Game(Unit):...
    
class Tiebreak(Game):...

The variable game in Set.play_game() will either be an instance of Game or of Tiebreak. When the code detects a 6-6 score, it recursively calls play_game() with the tiebreak=True argument. This call runs the tiebreak and updates the set’s score and winner, since there will always be a set winner after a tiebreak is played.

Your job is to test the tiebreak scenario now, using the same play_tennis.py you have from the previous section.

Updating Game.__repr__()

Tiebreak inherits everything from Game except for the method you overrode. There’s a minor issue with this. The __repr__() dunder method in Game uses the word “Game” in its string representation. This means that Tiebreak.__repr__() will also use the substring “Game” in its representation.

You can override __repr__() in Tiebreak if you wish, and copy-paste the code from Game, replacing a single word. Instead, you can make Game.__repr__() more generic:

# tennis.py

# ...

class Game(Unit):
 # ...

    def __repr__(self):
        return (
            f"{self.__class__.__name__}(set={self.set!r}, "
            f"game_number={self.game_number})"
        )

# ...

You use self.__class__.__name__ to refer to the instance’s class name. This will be “Game” when the instance is of type Game and “Tiebreak” when the instance is of type Tiebreak.

Completing The Match Class

You’re almost there. All that’s left is to complete the Match class. You’ll see many patterns you’re familiar with from earlier parts of this article.

You can refactor Match to inherit from Unit and write methods play_set() and play_match():

# tennis.py

class Player:...

class Unit:
    def __init__(self, players=(Player(), Player())):
        self.players = players
        self.score = {
            self.players[0]: 0,  # The key is of type Player
            self.players[1]: 0,
        }
        self.winner = None

    def get_winner(self):
        return self.winner

    def get_score(self):
        return self.score

    def is_running(self):
        return self.winner == None

class Match(Unit):
    def __init__(
        self,
        player_1=Player(),
        player_2=Player(),
        best_of_5=True,
    ):
        super().__init__(players=(player_1, player_2))
        self.best_of_5 = best_of_5
        self.sets_to_play = 5 if best_of_5 else 3
        self.sets = []

    def play_set(self):
        set = Set(self, len(self.sets) + 1)
        self.sets.append(set)

        while set.is_running():
            set.play_game()
        set_winner = set.get_winner()
        # Update set score for player who won set
        self.score[set_winner] += 1

        # If player has won 2 sets if best-of-three
        # or 3 sets if best-of-five, match is over
        if self.score[set_winner] == self.sets_to_play // 2 + 1:
            self.winner = set_winner

class Set(Unit):...

class Game(Unit):...

class Tiebreak(Game):...

Match now inherits from Unit. Both Player objects are bundled into a tuple when passed to super().__init__(), as required by the initialistion of Unit.

You define play_set(), which creates a Set and appends it to the sets attribute. You keep playing games until the set is over. These tasks are taken care of by the Set and Game classes you wrote earlier.

The rules are much simpler when it comes to when the match ends compared to when sets and games end. The first player to win 2 sets in a best-of-three match or 3 sets in a best-of-five match wins the match.

You can test play_set() by updating the script in play_tennis.py:

# play_tennis.py

from tennis import Player, Match

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)

while test_match.is_running():
    test_match.play_set()

There’s no longer a need to import Set and create a set manually as the Match object will take care of this. You can test this out. It’s a bit tedious to do so. In Part 2 of this project, where you’ll be simulating a tennis match in Python, you’ll automate this process to simulate a match. However, it’s still worthwhile to test the code this way.

When you do, you’ll notice a slight issue. At the end of each game, the program displays the score of the current set. But it doesn’t show the previous sets. You can fix this in Set.play_game() when you print the current score. Instead of printing the set score, you can print the match score. You can achieve this by replacing self with self.match in the f-string in Set.play_game().

However, you’ll need to write the string representations for Match first:

# tennis.py

class Player:...

class Unit:...

class Match(Unit):
    def __init__(
        self,
        player_1=Player(),
        player_2=Player(),
        best_of_5=True,
    ):
        super().__init__(players=(player_1, player_2))
        self.best_of_5 = best_of_5
        self.sets_to_play = 5 if best_of_5 else 3
        self.sets = []

    def play_set(self):
        set = Set(self, len(self.sets) + 1)
        self.sets.append(set)

        while set.is_running():
            set.play_game()
        set_winner = set.get_winner()
        # Update set score for player who won set
        self.score[set_winner] += 1

        # If player has won 2 sets if best-of-three
        # or 3 sets if best-of-five, match is over
        if self.score[set_winner] == self.sets_to_play // 2 + 1:
            self.winner = set_winner

    def __str__(self):
        return "".join([str(set) for set in self.sets])

    def __repr__(self):
        return (
            f"Match("
            f"player_1={self.players[0]}, "
            f"player_2={self.players[1]}, "
            f"best_of_5={self.best_of_5})"
        )

class Set(Unit):
    def __init__(self, match: Match, set_number=0):...

    def play_game(self, tiebreak=False):
        # Creat a Game object and append to .games list
        if tiebreak:
            game = Tiebreak(self, len(self.games) + 1)
        else:
            game = Game(self, len(self.games) + 1)
        self.games.append(game)

        # Ask for user input to record who won point
        print(
            f"\nRecord point winner: "
            f"Press 1 for {self.players[0]} | "
            f"Press 2 for {self.players[1]}"
        )
        while game.is_running():
            point_winner_idx = (
                int(input("\nPoint Winner (1 or 2) ->")) - 1
            )
            game.score_point(self.players[point_winner_idx])
            print(game)

        # Game over - update set score
        self.score[game.winner] += 1
        print(f"\nGame {game.winner.name}")
        print(f"\nCurrent score: {self.match}")

        # Check stage within set
        # If it's an early stage of the set and no one
        # reached 6 or 7 games, there's nothing else to do
        # and method can return
        if (
            6 not in self.score.values()
            and 7 not in self.score.values()
        ):
            return
        # Rest deals with latter stages of set when at least
        # one player is on 6 games
        # Check for 6-6 score
        if list(self.score.values()) == [6, 6]:
            self.play_game(tiebreak=True)
            return
        # …7-5 or 7-6 score (if tiebreak was played, score
        # will be 7-6)
        for player in self.players:
            # player reaches 7 games
            if self.score[player] == 7:
                self.winner = player
                return
            # player reaches 6 games
            # and 6-6 and 7-6 already ruled out
            if self.score[player] == 6:
                # Exclude 6-5 scenario
                if 5 not in self.score.values():
                    self.winner = player

    def __str__(self):...

    def __repr__(self):...

class Game(Unit):...

class Tiebreak(Game):...

Finally, there’s one last method to define in Match:

# tennis.py

class Player:...

class Unit:...

class Match(Unit):
    def __init__(
        self,
        player_1=Player(),
        player_2=Player(),
        best_of_5=True,
    ):
        super().__init__(players=(player_1, player_2))
        self.best_of_5 = best_of_5
        self.sets_to_play = 5 if best_of_5 else 3
        self.sets = []

    def play_set(self):
        set = Set(self, len(self.sets) + 1)
        self.sets.append(set)

        while set.is_running():
            set.play_game()
        set_winner = set.get_winner()
        # Update set score for player who won set
        self.score[set_winner] += 1

        # If player has won 2 sets if best-of-three
        # or 3 sets if best-of-five, match is over
        if self.score[set_winner] == self.sets_to_play // 2 + 1:
            self.winner = set_winner

    def play_match(self):
        while self.is_running():
            self.play_set()
        print(f"\nWinner: {self.winner}")
        print(f"Score: {self}")

    def __str__(self):
        return "".join([str(set) for set in self.sets])

    def __repr__(self):
        return (
            f"Match("
            f"player_1={self.players[0]}, "
            f"player_2={self.players[1]}, "
            f"best_of_5={self.best_of_5})"
        )

class Set(Unit):...

class Game(Unit):...

class Tiebreak(Game):...

And play_tennis.py can now be simplified further to:

# play_tennis.py

from tennis import Player, Match

nadal = Player("Rafael Nadal", 2000)
djokovic = Player("Novak Djokovic", 2000)

test_match = Match(nadal, djokovic)

test_match.play_match()

Now, you can run an entire tennis match, assigning one point at a time until one of the players wins the match.

Note: Final, full version of the code is available at the end of this article.

Simulating A Tennis Match in Python

This article is already rather long. So, instead of adding more, I’ll wrap up here and publish Part 2 as a separate, much shorter article.

In Part 2, you’ll add a bit more code to your classes to add the option to simulate a tennis match. You won’t need to assign points to players manually, but you’ll let the code do this for you. The code will assign points with a likelihood which depends on the players’ ranking points.

This will enable you to run simulations of hundreds or thousands of matches and analyse how certain parameters affect tennis match results. Here are two previews of the kind of results you’ll obtain from these simulations:

Final Words

In this article, you’ve explored the world of object-oriented programming in Python by simulating a tennis match. The key aspects of this topic you learnt about are:

  • Creating classes in Python using the class keyword
  • Initialising objects using __init__()
  • Defining methods in a class
  • Creating string representations for the class using __str__() and __repr__()
  • Creating classes using inheritance

There’s still more to explore in object-oriented programming. But the key point to remember is not so much the technical detail—that’s important too—but the philosophy behind this programming paradigm.

That’s game, set, and match for this article…

Further Reading

Final Full Version Of tennis.py

# tennis.py

class Player:
    def __init__(self, name="", ranking_points=0):
        self.name = name
        self.ranking_points = ranking_points

    def update_ranking_points(self, points_change):
        self.ranking_points += points_change

    def __str__(self):
        return self.name

    def __repr__(self):
        return (
            f"Player(name='{self.name}', "
            f"ranking_points={self.ranking_points})"
        )

        
class Unit:
    def __init__(self, players=(Player(), Player())):
        self.players = players
        self.score = {
            self.players[0]: 0,  # The key is of type Player
            self.players[1]: 0,
        }
        self.winner = None

    def get_winner(self):
        return self.winner

    def get_score(self):
        return self.score

    def is_running(self):
        return self.winner == None

      
class Match(Unit):
    def __init__(
        self,
        player_1=Player(),
        player_2=Player(),
        best_of_5=True,
    ):
        super().__init__(players=(player_1, player_2))
        self.best_of_5 = best_of_5
        self.sets_to_play = 5 if best_of_5 else 3
        self.sets = []

    def play_set(self):
        set = Set(self, len(self.sets) + 1)
        self.sets.append(set)

        while set.is_running():
            set.play_game()
        set_winner = set.get_winner()
        # Update set score for player who won set
        self.score[set_winner] += 1

        # If player has won 2 sets if best-of-three
        # or 3 sets if best-of-five, match is over
        if self.score[set_winner] == self.sets_to_play // 2 + 1:
            self.winner = set_winner

    def play_match(self):
        while self.is_running():
            self.play_set()
        print(f"\nWinner: {self.winner}")
        print(f"Score: {self}")

    def __str__(self):
        return "".join([str(set) for set in self.sets])

    def __repr__(self):
        return (
            f"Match("
            f"player_1={self.players[0]}, "
            f"player_2={self.players[1]}, "
            f"best_of_5={self.best_of_5})"
        )
        
        
class Set(Unit):
    def __init__(self, match: Match, set_number=0):
        super().__init__(match.players)
        self.match = match
        self.set_number = set_number
        self.games = []

    def play_game(self, tiebreak=False):
        # Creat a Game object and append to .games list
        if tiebreak:
            game = Tiebreak(self, len(self.games) + 1)
        else:
            game = Game(self, len(self.games) + 1)
        self.games.append(game)

        # Ask for user input to record who won point
        print(
            f"\nRecord point winner: "
            f"Press 1 for {self.players[0]} | "
            f"Press 2 for {self.players[1]}"
        )
        while game.is_running():
            point_winner_idx = (
                int(input("\nPoint Winner (1 or 2) ->")) - 1
            )
            game.score_point(self.players[point_winner_idx])
            print(game)

        # Game over - update set score
        self.score[game.winner] += 1
        print(f"\nGame {game.winner.name}")
        print(f"\nCurrent score: {self.match}")

        # Check stage within set
        # If it's an early stage of the set and no one
        # reached 6 or 7 games, there's nothing else to do
        # and method can return
        if (
            6 not in self.score.values()
            and 7 not in self.score.values()
        ):
            return
        # Rest deals with latter stages of set when at least
        # one player is on 6 games
        # Check for 6-6 score
        if list(self.score.values()) == [6, 6]:
            self.play_game(tiebreak=True)
            return
        # …7-5 or 7-6 score (if tiebreak was played, score
        # will be 7-6)
        for player in self.players:
            # player reaches 7 games
            if self.score[player] == 7:
                self.winner = player
                return
            # player reaches 6 games
            # and 6-6 and 7-6 already ruled out
            if self.score[player] == 6:
                # Exclude 6-5 scenario
                if 5 not in self.score.values():
                    self.winner = player

    def __str__(self):
        return "-".join(
            [str(value) for value in self.score.values()]
        )

    def __repr__(self):
        return (
            f"Set(match={self.match!r}, "
            f"set_number={self.set_number})"
        )

        
class Game(Unit):
    points = 0, 15, 30, 40, "Ad"  # Class attribute

    def __init__(self, set: Set, game_number=0):
        super().__init__(set.players)
        self.set = set
        self.game_number = game_number

    def score_point(self, player: Player):
        if self.winner:
            print(
              "Error: You tried to add a point to a completed game"
            )
            return
        game_won = False
        current_point = self.score[player]
        # Player who wins point was on 40
        if self.score[player] == 40:
            # Other player is on Ad
            if "Ad" in self.score.values():
                # Update both players' scores to 40
                for each_player in self.players:
                    self.score[each_player] = 40
            # Other player is also on 40 (deuce)
            elif list(self.score.values()) == [40, 40]:
                # Point winner goes to Ad
                self.score[player] = "Ad"
            # Other player is on 0, 15, or 30
            else:
                # player wins the game
                game_won = True
        # Player who wins point was on Ad
        elif self.score[player] == "Ad":
            # player wins the game
            game_won = True
        # Player who wins point is on 0, 15, or 30
        else:
            self.score[player] = Game.points[
                Game.points.index(current_point) + 1
            ]

        if game_won:
            self.score[player] = "Game"
            self.winner = player

    def __str__(self):
        score_values = list(self.score.values())
        return f"{score_values[0]} - {score_values[1]}"

    def __repr__(self):
        return (
            f"{self.__class__.__name__}(set={self.set!r}, "
            f"game_number={self.game_number})"
        )

        
class Tiebreak(Game):
    def __init__(self, set: Set, game_number=0):
        super().__init__(set, game_number)

    def score_point(self, player: Player):
        if self.winner:
            print(
              "Error: You tried to add a point to a completed game"
            )
            return
        # Add point to player
        self.score[player] += 1
        # Tiebreak over only if player has 7 or more points
        # and there's at least a 2 point-gap
        if (
            self.score[player] >= 7
            and self.score[player] - min(self.score.values()) >= 2
        ):
            self.winner = player

Get the latest blog updates

No spam promise. You’ll get an email when a new blog post is published


The post Simulating a Tennis Match Using Object-Oriented Programming in Python—Wimbledon Special Part 1 appeared first on The Python Coding Book.


Viewing all articles
Browse latest Browse all 22463

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>