A Python Model for Ping Pong Matches
See Python: Tips and Tricks for similar articles.
A while back, I started playing table tennis more seriously. I’m now in sort of a no-man’s land, in which I can beat most people who consider it a basement game, but can’t beat very many people who consider it a sport.
Because I’d rather write Python code than practice against my robot, I’ve created a model in Python to try to get an idea of what I should practice most to win more games. I’m unlikely to follow through on the practice, but I am likely to refine the model if people have ideas of how it could be made better. So, if there are any ping pong programmers out there, please feel free to make suggestions.
Before we get into the Python code, let me explain a little about table tennis.
Relevant Table Tennis Rules:
- Matches are best of 5 games.
- Games are played to 11 points, but you must win by 2.
- The server changes every two points up until deuce deuce (11-11), at which point it changes every point.
- The first to serve in the first game is chosen randomly and then alternated each subsequent game.
- In table tennis, you only get one fault.
How Points Play Out
The server tries to make it difficult for his opponent to return the serve well. Ideally, the opponent will not be able to return the serve at all, but if he does, the server hopes the return is weak enough so that he can easily put the third ball away. You can think of a point like this:
- Server serves
- Returner returns serve
- Server attempts putaway
- Returner volleys
- Server volleys
- Returner volleys
Once the volley has lasted for five hits, the odds of a player successfully returning a shot probably don’t change much with each successive hit.
So, when two players face each other, you can create a model to show how the matches are likely to come out based on the players’ likelihood to fail on any given shot. For example, imagine these two players failure rates when they play each other:
| Leira | Nat | |
|---|---|---|
| Serve | .05 | .1 |
| Return of Serve | .1 | .5 |
| 3rd Shot | .3 | .25 |
| 4th Shot | .2 | .4 |
| 5th Shot | .2 | .25 |
| 6th Shot | .2 | .25 |
- When Leira serves first, he almost always gets it in.
- Nat has a lot of trouble with Leira’s serve. He hits half of them into the net or off the table.
- When Nat does get it back, he sets Leira up for a putaway. And Leira generally goes for it, but he hits it out 30% of the time.
- As per above, Leira’s 3rd shot is often, but not always, an attempted putaway. Nat fails to return that shot about 40% of the time.
- If it gets to a fifth shot, the point becomes more of a volley and the chances of a putaway or an error on any given shot are lower. Leira only fails to return the fifth and subsequent shots 20% of the time.
- At this point in the volley, Nat fails to return shots 25% of the time.
- When Nat serves first, he gets 90% of his serves in.
- Leira has little trouble returning Nat’s serve. He gets 90% of them back.
- Nat doesn’t often set himself up for the 3rd shot kill, so he moves right to volley mode, in which he fails to return shots 25% of the time.
- Leira is in volley mode at this point too and fails to return shots 20% of the time.
So, let’s consider our players in Python:
# Leira
player.name = 'Leira'
player.fail_rates = [.05, .1, .3, .2]
# Nat
player.name = 'Nat'
player.fail_rates = [.1, .5, .25, .4, .25]My program assumes that the fail rate for all hits after the last one indicated in the player’s fail_rates list is the same as the fail rate for the last one listed. So, because Leira has the same fail rate for shots 4, 5, and 6 and all subsequent shots, we only have to indicate the fail rate for his first four shots.
So, those are my assumptions. Here’s the code:
import random
class Player:
def __init__(self, name, fail_rates):
"""Creates a new player
Keyword arguments:
name (str) -- Player's name
fail_rates (list of floats) -- Fail rates of each shot in point
"""
self.name = name
self.fail_rates = fail_rates
def get_fail_rate(self, hit_num):
"""Returns the fail rate based on the hit number in the point.
If fail_rates doesn't go up that high, it returns the last
element in fail_rates.
Keyword arguments:
hit_num (int): Hit number in point
"""
try:
return self.fail_rates[hit_num]
except IndexError as e:
return self.fail_rates[-1]
def add_fail_rate(self, fr):
"""Appends fr to fail_rates.
Keyword arguments:
fr (float): Fail rate
"""
self.fail_rates.append(fr)
def print_info(self):
"""Prints report on player"""
print("{}:\n - Fail rates: ".format(self.name.upper()))
for i, r in enumerate(self.fail_rates, 1):
print("\t\t", i, ". ", "{:.0%}".format(r), sep="")
class Match:
def __init__(self, players):
"""Creates a new match
Keyword arguments:
players: List of Player objects
Attributes
players: List of Player objects
first_to_serve (Player): Player to serve first in game 1
games: List of Game objects
winner (Player): Match winner
loser (Player): Match loser
"""
random.shuffle(players) # shuffles players in place
self.players = players
self.first_to_serve = self.players[0]
self.games = []
self.winner = None
self.loser = None
def play(self):
"""Play match"""
while not self._is_match_over():
game = Game(self.players)
self.games.append(game)
game.play()
self.players.reverse() # changes who serves first
def _is_match_over(self):
"""Return True if match is over"""
if len(self.games) < 3:
return False
if self._get_wins(self.players[0]) == 3:
self.winner = self.players[0]
self.loser = self.players[1]
return True
elif self._get_wins(self.players[1]) == 3:
self.winner = self.players[1]
self.loser = self.players[0]
return True
else:
return False
def _get_wins(self, player):
"""Return the number of wins a player had in the match
Keyword arguments:
player: Player object
"""
wins = len([game for game in self.games if game.winner is player])
return wins
def print_results(self):
"""Prints report of match"""
print("First to Serve: ", self.first_to_serve.name)
print("Winner: ", self.winner.name)
print("Loser: ", self.loser.name)
print(
"Score: {}-{}".format(
self._get_wins(self.winner), self._get_wins(self.loser)
)
)
more_details = input("Want more details? y/n: ").lower()
if more_details != "n":
for i, game in enumerate(self.games, 1):
print("GAME", i)
game.print_results()
class Game:
def __init__(self, players):
"""Creates a new game
Keyword arguments:
players: List of Player objects
Attributes
players: List of Player objects (first in list serves first)
first_to_serve (Player): Player to serve first in game
winner (Player): Game winner
loser (Player): Game loser
"""
self.players = players
self.first_to_serve = self.players[0]
self.points = []
self.winner = None
self.loser = None
def get_score(self):
"""Returns score as dictionary"""
p1_points = len([p for p in self.points if p.winner is self.players[0]])
p2_points = len(self.points) - p1_points
return {self.players[0]: p1_points, self.players[1]: p2_points}
def play(self):
"""Play game"""
server, returner = self.players
while not self._is_game_over():
score = self.get_score()
hit_num = 0
if len(self.points) >= 22 or (self.points and len(self.points) % 2 == 0):
server, returner = returner, server # change server
while True: # rally continues
if random.random() < server.get_fail_rate(hit_num): # server missed
point = Point(server, returner, server, hit_num)
self.points.append(point)
break
hit_num += 1
if random.random() < returner.get_fail_rate(hit_num): # returner missed
point = Point(server, server, returner, hit_num)
self.points.append(point)
break
hit_num += 1
def _is_game_over(self):
"""Return True if game is over"""
score = self.get_score()
p1 = score[self.players[0]]
p2 = score[self.players[1]]
if abs(p1 - p2) > 1 and (p1 >= 11 or p2 >= 11):
if p1 > p2:
self.winner = self.players[0]
self.loser = self.players[1]
else:
self.winner = self.players[1]
self.loser = self.players[0]
return True
else:
return False
def print_results(self):
"""Prints report of game"""
print(" - First to Serve: ", self.first_to_serve.name)
print(" - Winner: ", self.winner.name)
print(" - Loser: ", self.loser.name)
scores = list(self.get_score().values())
print(" - Score: {}-{}".format(max(scores), min(scores)))
for i, p in enumerate(self.points, 1):
print(
"\t{:>3}".format(i),
". Server: ",
p.server.name,
", Winner: ",
p.winner.name,
", Volley length: ",
p.volley_length,
sep="",
)
class Point:
def __init__(self, server, winner, loser, num_hits):
"""Creates a new point
Keyword arguments:
server (Player): server of point
winner (Player): winner of point
loser (Player): loser of point
num_hits (int): number of hits in point, including serve
"""
self.server = server
self.winner = winner
self.loser = loser
self.volley_length = num_hits
def print_summary(matches, players):
"""Prints summary report.
Used for when many matches are played.
"""
num = len(matches)
players[0].print_info()
players[1].print_info()
p1_wins = len([match for match in matches if match.winner is players[0]])
p2_wins = len([match for match in matches if match.winner is players[1]])
print(
"{} Wins: {}. {} Wins: {}".format(
players[0].name, p1_wins, players[1].name, p2_wins
)
)
server_wins = len(
[match for match in matches if match.winner is match.first_to_serve]
)
print("First to serve wins {:.0%} of the time.".format(server_wins / num))
num_points = 0
num_hits = 0
for match in matches:
for game in match.games:
num_points += len(game.points)
num_hits += sum(p.volley_length for p in game.points)
volley_length = num_hits / num_points
print("The average volley length is {:.2f} hits.".format(volley_length))
def play_one(players):
"""Plays one match and prints results"""
print()
match = Match(players)
match.play()
match.print_results()
if input("Enter to play again. Q to quit. ").lower() == "q":
print("Goodbye!")
else:
play_one(players)
def play(players, num):
"""Plays num matches
Keyword arguments:
num (int): Number of matches to play
"""
if num == 1:
play_one(players)
return
print()
matches = []
for i in range(num):
match = Match(players)
match.play()
matches.append(match)
print_summary(matches, players)
if input("Enter to play again. Q to quit. ").lower() == "q":
print("Goodbye!")
else:
play(players, num)
def create_player(name):
"""Creates and returns player based on user input
Keyword arguments:
name (str): Player name
"""
try:
player_sfr = float(input(name + "'s Serve Fail Rate: "))
player_srfr = float(input(name + "'s Serve Return Fail Rate: "))
player = Player(name, [player_sfr, player_srfr])
i = 2
while True:
i += 1
try:
vfr = float(input(name + "'s hit " + str(i) + " fail rate: "))
except:
break
else:
player.add_fail_rate(vfr)
except:
print("Let's try this again. Enter floats.")
return add_player(name)
else:
return player
testing = True
def main():
"""Main Program
If num_matches is 1, then only one match is played and
the results of all the games (including each point) are
reported.
If num_matches is more than 1, then a summary of the results
is displayed.
"""
if testing:
# CHANGE PLAYER INFO BELOW
players = [
Player("Leira", [0.05, 0.1, 0.3, 0.3, 0.2]),
Player("Nat", [0.05, 0.5, 0.25, 0.4, 0.25]),
]
num_matches = 1000
else:
players = []
player1_name = input("Player 1 Name: ")
player2_name = input("Player 2 Name: ")
players.append(create_player(player1_name))
players.append(create_player(player2_name))
num_matches = int(input("Num matches: "))
play(players, num_matches)
main()
Feel free to play around with it and let me know if you have any suggestions for improving the model or the code.
