Do you know the offside rule in football*? Or do you want to test whether someone else knows it well enough? Either way, it’s time to write an offside rule quiz in Python using object-oriented programming.
(*Some of you may call it “soccer”)
Here’s what the quiz will look like. The program presents you with 10 scenarios and you need to guess whether there’s an offside position. The program will then give you the correct answer and assigns a point if you got it right.
Each scenario shows the 22 players on the pitch with arrows to indicate which direction the team is attacking. The arrows showing teammates all point in the same direction. The team is attacking in this direction.
One player on the attacking team has the ball, which you can see as a white dot (of course). You’re presented with a snapshot at the time the player with the ball kicks it forward. Is it offside? You need to decide!
Article Summary
In this article, you’ll:
- Create several Python classes to represent:
- an
Action
(a snapshot of the players’ positions in a game) - a
Player
- a
Goalkeeper
- a
Team
- an
- Practise creating data attributes (instance variables) and methods
- Use inheritance
- Link instances of one class with instances of another class
- Learn the offside rule if you don’t know it already!
You can think of this article in one of two ways. You can either learn the offside rule in football by using object-oriented programming. Or you can learn object-oriented programming in Python through the offside rule in football!
Disclosure
I don’t particularly like watching football anymore. I used to when I was younger, but I can’t remember the last time I watched a game, in full or in part, which didn’t feature either of my children!
Also, I didn’t try to make the “graphics” look good in this quiz. You go ahead and make it look pretty if you want!
Planning The Program
Very roughly speaking, a player is in an offside position if he or she doesn’t have at least two opponent players in front of them when a teammate kicks the ball forward. It’s a bit more complex than this, but this will do for now. We’ll look at some of the special cases as we write the code.
Here’s the plan of what you’ll need the program to do:
- Place 22 players randomly on a pitch, half facing one way and the other half facing the other way
- Assign one team as the attacking team. Choose one of its players who will have the ball
- Determine whether the frontmost player of the attacking team is in an offside position. You won’t worry about other subtleties, such as whether the player is actively participating in the action. That’s a subjective call. And, in any case, the program will only show a snapshot and not the whole action
- Repeat the test several times to turn the program into a quiz
You’ll use Python’s turtle
module to display the scenario. This module is in Python’s standard library. Don’t worry if you’ve never used this module. I’ll explain the bits you’ll need as you use them in this article. The “turtle” we’ll talk about is the object which will move across the screen.
You’ll use classes to represent various entities in this program. This article assumes some familiarity with object-oriented programming but not any high level of expertise. If you need to read more about defining and using classes in Python, you can read Chapter 7 in The Python Coding Book about Object-Oriented Programming before going ahead with this article.
The Offside Rule Quiz in Python: Getting Started
You’ll write your code in two separate files:
offside.py
: This file will contain the classes you’ll defineoffside_rule_quiz.py
: This one will contain the code that runs the quiz. You’ll also use it to test your classes as you implement them
You can start by creating offside.py
. You’ll start by defining the Action
class which will take care of each action or scenario where you’ll need to decide whether there’s an offside position.
This class will also include the football pitch. This will be the screen you create using the turtle
module. You can start writing the Action
class in offside.py
:
# offside.py import turtle class Action: pitch_size = 800, 600 pitch_colour = "forest green" def __init__(self): # Pitch is the turtle Screen object # (technically _Screen object, as Screen() is a # function, but we can ignore this subtlety) self.pitch = turtle.Screen() self.pitch.setup( Action.pitch_size[0], Action.pitch_size[1], ) self.pitch.bgcolor(Action.pitch_colour)
The class variables pitch_size
and pitch_colour
are the first things you add to the class. These will be the same for all Action
instances. They’re not specific to each instance.
You also define the __init__()
method to initialise an Action
instance. This creates a screen using the turtle
module which you assign to the attribute pitch
.
setup()
is a method from the turtle
module which sets the size of the window you create. Since pitch_size
is a class variable (or class attribute), you can use the name of the class to refer to it instead of self
.
bgcolor()
is another method in the turtle
module which changes the window’s background colour.
Now, you can create a second file called offside_rule_quiz.py
and create an instance of the Action
class:
# offside_rule_quiz.py from offside import Action game = Action()
You’ll see a window flash briefly in front of your eyes when you run this script. That’s because your program creates the window using the turtle
module but then terminates immediately. There’s nothing else in the program.
The turtle
module has the function done()
which runs the main loop of the animation. This will keep the window open and the program running until the window is closed:
# offside_rule_quiz.py import turtle from offside import Action game = Action() turtle.done()
This script will now show you the window with the green football pitch:
The Players: Creating a Player
Class
You’ll need 22 players on the pitch. You can create a Player
class to take care of this. The players need to be displayed as a symbol on the pitch. This is what the Turtle
class is ideal for. Therefore, you can define the Player
class to inherit from turtle.Turtle
so that each Player
instance is a Turtle
instance with additional attributes:
# offside.py import random import turtle class Action: pitch_size = 800, 600 pitch_colour = "forest green" def __init__(self): # Pitch is the turtle Screen object # (technically _Screen object, as Screen() is a # function, but we can ignore this subtlety) self.pitch = turtle.Screen() self.pitch.setup( Action.pitch_size[0], Action.pitch_size[1], ) self.pitch.bgcolor(Action.pitch_colour) class Player(turtle.Turtle): def __init__(self, colour, direction): super().__init__() # Turtle methods self.penup() self.setheading(direction) self.color(colour) self.shape("triangle") # Methods specific to Player self.set_bounds() self.place_on_pitch() def set_bounds(self): """ Set the left, right, top, and bottom limits where a player can be located on the pitch. Leave a boundary at the edge of the pitch to avoid players being partially off the pitch """ pitch_half_width = Action.pitch_size[0] // 2 pitch_half_height = Action.pitch_size[1] // 2 self.left_bound = -int(pitch_half_width * 0.95) self.right_bound = int(pitch_half_width * 0.95) self.bottom_bound = -int(pitch_half_height * 0.95) self.top_bound = int(pitch_half_height * 0.95) def place_on_pitch(self): """Place player in a random position on the pitch""" self.setposition( random.randint(self.left_bound, self.right_bound), random.randint(self.bottom_bound, self.top_bound), )
Since Player
inherits from turtle.Turtle
, you call the initialisation method of the parent class using super().__init__()
. You can then use methods from the Turtle
class on self
and new methods you define for Player
.
The Turtle
methods you’re using are:
penup()
: raises the “drawing pen” so that when you move thePlayer
(which is also aTurtle
), it doesn’t draw any linessetheading()
: changes the direction thePlayer
is facingcolor()
: changes the colour of the shape drawn on the screen. You probably guessed this without needing the explanation!shape()
: changes the shape which represents the object on the screensetposition()
: changes the x- and y-coordinates of the object on the screen. This method is used inplace_on_pitch()
In the turtle
module, the centre of the screen has the coordinates (0, 0). Therefore, negative numbers for x represent the left half of the screen and negative y values represent the bottom half of the screen.
You define two new methods in the Player
class:
set_bounds()
: determines the left, right, bottom, and top limits of the pitch where you can draw the player. You’ve left a small gap to prevent the player from being right at the edge of the screen/pitchplace_on_pitch()
: places the player in a random position on the pitch, using the bounds calculated inset_bounds()
Remember that you should always use descriptive names for variables and methods (or functions). When naming a function or method, always start with a verb to clearly show what the function does.
You can test this code by adding a Player
in offside_rule_quiz.py
. This line is there just to test that everything works. You’ll need to remove it once you’ve tested this works, as you’ll be creating the players elsewhere in your code:
# offside_rule_quiz.py import turtle from offside import Action, Player game = Action() player = Player("orange", 180) turtle.done()
When you run offside_rule_quiz.py
, you’ll see a single player on the pitch, shown as an arrow:
Before you move on to create the teams, let’s look at the code you just added. Could you have placed the code within the methods set_bounds()
and place_on_pitch()
directly in __init__()
? Yes, you could have. These are design decisions that each programmer needs to make. There’s no clear right or wrong.
However, with practice and experience, you’ll get an intuition on whether to separate functionality into different methods. As a rule, if in doubt, it may be better to write separate methods rather than put everything in __init__()
as you may want to re-use these methods later. As it happens, later you’ll see a benefit from having these as separate methods when we talk about the goalkeepers.
The Teams: Creating a Team
Class
So far, you’ve got the “big picture” Action
class and the Player
class. You’ll need 22 players but split into two teams. So, you can now create a Team
class to deal with anything relating to the teams as a whole.
You’ll need a link between the players and the team. You can create a players
attribute in Team
which could be a list containing all the players. But you can also create a team
attribute in Player
to identify which team a player belongs to. So, as you write the Team
class, you’ll need to make a change to the Player
class, too. You’ll add the team
attribute to Player
and add another parameter in its __init__()
method:
# offside.py import random import turtle class Action: pitch_size = 800, 600 pitch_colour = "forest green" def __init__(self): # Pitch is the turtle Screen object # (technically _Screen object, as Screen() is a # function, but we can ignore this subtlety) self.pitch = turtle.Screen() self.pitch.setup( Action.pitch_size[0], Action.pitch_size[1], ) self.pitch.bgcolor(Action.pitch_colour) class Player(turtle.Turtle): def __init__(self, team, colour, direction): super().__init__() # Turtle methods self.penup() self.setheading(direction) self.color(colour) self.shape("triangle") # Attributes/Methods specific to Player self.team = team self.set_bounds() self.place_on_pitch() def set_bounds(self): """ Set the left, right, top, and bottom limits where a player can be located on the pitch. Leave a boundary at the edge of the pitch to avoid players being partially off the pitch """ pitch_half_width = Action.pitch_size[0] // 2 pitch_half_height = Action.pitch_size[1] // 2 self.left_bound = -int(pitch_half_width * 0.95) self.right_bound = int(pitch_half_width * 0.95) self.bottom_bound = -int(pitch_half_height * 0.95) self.top_bound = int(pitch_half_height * 0.95) def place_on_pitch(self): """Place player in a random position on the pitch""" self.setposition( random.randint(self.left_bound, self.right_bound), random.randint(self.bottom_bound, self.top_bound), ) class Team: def __init__(self, player_colour, end): self.player_colour = player_colour self.end = end # -1 if team playing left to right # 1 if team playing right to left self.players = [] self.direction = 90 + 90 * self.end self.create_team() def create_team(self): for _ in range(10): self.players.append( Player(self, self.player_colour, self.direction) )
You’ve added the parameter team
in Player.__init__()
and then made it an attribute by adding self.team = team
.
You also define the Team
class with two input parameters. However, there are more than two attributes in this class so far. Let’s look at them:
player_colour
: a data attribute showing the colour used to display the player on the screenend
: a data attribute which will be either -1 or 1.end
is -1 if the team is attacking from left to write and 1 if it’s attacking from right to leftplayers
: a data attribute which starts as an empty list but which will be populated withPlayer
instancesdirection
: a data attribute containing an angle showing the players’ orientation. The angle is 180º whenend
is 1 (facing to the left) and 0º whenend
is -1 (facing to the right)create_team()
: a method which createsPlayer
instances and adds them to theplayers
list.self
,self.player_colour
, andself.direction
are passed as arguments when creatingPlayer
and they’re assigned to the parametersteam
,colour
, anddirection
inPlayer.__init__()
Have you spotted the “typo” in the code above? Or something you’re sure must be a typo? There should be 11 players in a football team, not 10! You’ll deal with the goalkeeper a bit later. So, you’ll include only the 10 outfield players for the time being.
You can test this code works in offside_rule_quiz.py
. As mentioned earlier, the lines you’re adding now are just there temporarily. You’ll remove them later:
# offside_rule_quiz.py import turtle from offside import Action, Team game = Action() first_team = Team("orange", -1) second_team = Team("light blue", 1) turtle.done()
When you run this script, you’ll see the two teams on the pitch, with all players in random positions:
Speeding up the process of drawing on the screen
You probably noticed that it took a while for each Player
to be displayed in its random pitch position on the screen. Life’s too short. We can speed things up when using the turtle
module through the pair of methods tracer()
and update()
. These are screen methods.
tracer(0)
will stop displaying each step when moving turtles across the screen. update()
will update the display by placing all turtles in their new positions instantaneously. You can think of this as getting the players to move in the background and then only showing them once they’ve reached their positions. This will speed up the animation considerably.
You can incorporate these methods in the Action
class:
# offside.py import random import turtle class Action: pitch_size = 800, 600 pitch_colour = "forest green" def __init__(self): # Pitch is the turtle Screen object # (technically _Screen object, as Screen() is a # function, but we can ignore this subtlety) self.pitch = turtle.Screen() self.pitch.tracer(0) self.pitch.setup( Action.pitch_size[0], Action.pitch_size[1], ) self.pitch.bgcolor(Action.pitch_colour) def update(self): self.pitch.update() class Player(turtle.Turtle): # ... class Team: # ...
You set the tracer to zero when you create the Action
instance, and you create an Action
method called update()
which calls the update()
method in the turtle
module.
Why not use the update()
method in the turtle
module directly? You can do so, but from the perspective of a programmer using the Action
class, having an Action
method will make more sense, and the user doesn’t need to know how you’ve implemented the Action
class. Someone using this class doesn’t need to know that you have a pitch
attribute containing the screen object from the turtle
module!
Let’s test this with a small change in offside_rule_quiz.py
:
# offside_rule_quiz.py import turtle from offside import Action, Team game = Action() first_team = Team("orange", -1) second_team = Team("light blue", 1) game.update() turtle.done()
When you run this script, you’ll see that all 20 players will appear instantly on the pitch!
The GoalKeepers: Creating a GoalKeeper
Class
Let’s start with some honesty: you don’t need a GoalKeeper
class. The offside rule doesn’t differentiate between goalkeepers and outfield players. So, you could just create 11 regular players and move on.
But why take a shortcut when the slightly longer route is so rich with “goodness”? And object-oriented programming makes it very easy to add a GoalKeeper
class.
A goalkeeper is a player. So, you can use the Player
class as a starting point and then make the few changes needed. In this case, the difference between a goalkeeper and an outfield player will be:
- The goalkeeper wears a different colour
- The goalkeeper’s random position on the pitch will be limited to a region close to their goal
So, you can create a GoalKeeper
class which inherits from Player
. (Note: when a section of code hasn’t changed since the previous sections in this article, I’ll show it as # ...
to avoid very long code blocks repeating the same code over and over again):
# offside.py import random import turtle class Action: # ... class Player(turtle.Turtle): # ... class GoalKeeper(Player): def __init__(self, team, colour, direction): super().__init__(team, colour, direction) def set_bounds(self): """ Set the left, right, top, and bottom limits where a goalkeeper can be located on the pitch. Goalkeeper is located close to own goal """ pitch_half_width = Action.pitch_size[0] // 2 pitch_half_height = Action.pitch_size[1] // 2 self.left_bound, self.right_bound = sorted( [ self.team.end * pitch_half_width * 0.98, self.team.end * pitch_half_width * 0.85, ] ) self.bottom_bound = -pitch_half_height * 0.5 self.top_bound = pitch_half_height * 0.5 class Team: def __init__(self, player_colour, keeper_colour, end): self.player_colour = player_colour self.keeper_colour = keeper_colour self.end = end # -1 if team playing left to right # 1 if team playing right to left self.players = [] self.direction = 90 + 90 * self.end self.create_team() def create_team(self): self.players.append( GoalKeeper(self, self.keeper_colour, self.direction) ) for _ in range(10): self.players.append( Player(self, self.player_colour, self.direction) )
The Goalkeeper
class is nearly identical to the Player
class. The only difference is the set_bounds()
method which overrides the one in Player
. If the team is playing left to right, and therefore the team’s end
attribute is -1, the left and right bounds for the goalkeeper will be in the half of the pitch represented by negative numbers. That’s the left-hand side. This ensures the goalkeeper is never placed too far from the goal.
However, if end
is 1, which means the team is playing right to left, the left and right bounds between which the goalkeeper is randomly placed are on the right of the pitch. Note that you’re using the built-in sorted()
function to make sure that the smallest number is always the first one in the list.
You also added a new parameter in Team.__init__()
. This parameter is keeper_colour
. You also converted it into a data attribute using self.keeper_colour = keeper_colour
. Finally, you added the goalkeeper to the list of players in create_team()
.
Since Team.__init__()
now has an extra parameter, you’ll need to add an extra colour when creating the teams in offside_rule_quiz.py
to test your code:
# offside_rule_quiz.py import turtle from offside import Action, Team game = Action() first_team = Team("orange", "dark salmon", -1) second_team = Team("light blue", "dark blue", 1) game.update() turtle.done()
When you run this script, you’ll see all 22 players, including the two goalkeepers “wearing” different colours:
The Attacking Team: Deciding Which Team And Player Has The Ball
So far, you’ve created two teams with 11 players each. The teams are facing different directions and all their players are placed in random positions on the pitch.
However, in an action where you need to determine whether there’s an offside position, you need to know which team is attacking and which is defending. And one of the attacking team’s players must have the ball.
Let’s go back to the Action
class and you can define three new methods:
create_teams()
choose_attacking_team()
place_ball()
To simplify the quiz, we’ll then add these methods to Action.__init__()
, but you can also choose to call them from elsewhere in your code if you prefer:
# offside.py import random import turtle class Action: pitch_size = 800, 600 pitch_colour = "forest green" def __init__(self): # Pitch is the turtle Screen object # (technically _Screen object, as Screen() is a # function, but we can ignore this subtlety) self.pitch = turtle.Screen() self.pitch.tracer(0) self.pitch.setup( Action.pitch_size[0], Action.pitch_size[1], ) self.pitch.bgcolor(Action.pitch_colour) self.create_teams() self.choose_attacking_team() self.place_ball() def update(self): self.pitch.update() def create_teams(self): """Create two teams facing opposite directions""" self.left_to_right_team = Team( "orange", "dark salmon", -1 ) self.right_to_left_team = Team( "light blue", "dark blue", 1 ) def choose_attacking_team(self): """Pick which team is attacking in this action""" self.attacking_team_indicator = random.choice([-1, 1]) if self.attacking_team_indicator == -1: self.attacking_team = self.left_to_right_team self.defending_team = self.right_to_left_team else: self.attacking_team = self.right_to_left_team self.defending_team = self.left_to_right_team self.attacking_direction = ( 90 + 90 * self.attacking_team_indicator ) def place_ball(self): """ Assign ball to one of the players in the attacking team """ self.player_with_ball = random.choice( self.attacking_team.players ) ball = turtle.Turtle() ball.penup() ball.shape("circle") ball.color("white") ball.setposition(self.player_with_ball.position()) ball.setheading(self.attacking_direction) ball.forward(20) class Player(turtle.Turtle): # ... class GoalKeeper(Player): # ... class Team: # ...
create_teams()
This method creates the two Team
instances and assigns them to data attributes left_to_right_team
and right_to_left_team
. I’ve just hard-coded the colours in the code to keep things a bit simpler (as the code is already getting quite long). If you prefer to do something more clever in your code, please go ahead!
choose_attacking_team()
This method picks a random integer out of -1 and 1. If it picks -1, the team attacking is the one with the end
attribute equal to -1. This is the team attacking from left to right. You create an attacking_team
attribute which refers to the same Team
object that left_to_right_team
refers to. Note that you’re not creating a new object but merely adding another label to the same Team
object.
You do the same with defending_team
. Then you assign the same attributes to the opposite teams if the random number chosen is 1, meaning it’s the team playing right to left that’s attacking.
Finally, you set attacking_direction
which is either 0º or 180º. You’re using the value of attacking_team_indicator
which is either -1 or 1 to set this value. In the turtle
module, 0º points right and 180º points left.
I have deliberately mixed styles in this method to demonstrate some of the choices you’ll need to make when writing code. Instead of using the trick with attacking_team_indicator
to choose the attacking_direction
, I could have added a line to each of the if
and else
clauses and set this value to 0º or 180º directly.
Similarly, we could have avoided using an if...else
construct altogether by writing:
self.attacking_team, self.defending_team = [ self.left_to_right_team, self.right_to_left_team, ][:: self.attacking_team_indicator]
The list containing both teams is being either left unchanged or reversed using the slice [:: self.attacking_team_indicator]
as attacking_team_indicator
is either 1 or -1. However, this solution scores low on readability!
These are choices you will have to make when writing your own code. There’s no clear-cut rule on what’s readable and what isn’t. It’s a very subjective issue. Sometimes, it feels great to come up with some “clever” solution, like the slicing trick above. However, the more conventional approach may be preferable as it’s easier to read for others and for yourself in the future. Plus, it’s easier to debug.
You can also remove the one-liner setting attacking_direction
, if you prefer, and replace it with two lines, one in the if
block and one in the else
block.
place_ball()
This method picks a random player from the attacking team, creates a new Turtle
object to represent the ball, and places the ball at the “feet” of the chosen player. The only Turtle
methods you’ve not seen already in this article are:
position()
: returns the x- and y-coordinates of theTurtle
forward()
: moves theTurtle
in the direction it’s facing. The argument is the number of pixels theTurtle
will move
You can simplify offside_rule_quiz.py
to:
# offside_rule_quiz.py import turtle from offside import Action game = Action() game.update() turtle.done()
This gives you both teams, including one player who has the ball:
Last Two Defenders And Frontmost Attacker
Only three players matter when you need to determine whether there’s an offside situation. One of them is the attacking team’s player who’s furthest forward among his or her teammates. The other two are the two players on the defending team who are the furthest back. Often, the goalkeeper is one of these two players, but it doesn’t have to be so!
You can create two methods in the Team
class: find_two_back_players_xpos()
and find_front_player_xpos()
# offside.py import random import turtle class Action: # ... class Player(turtle.Turtle): # ... class GoalKeeper(Player): # ... class Team: def __init__(self, player_colour, keeper_colour, end): # ... def create_team(self): # ... def find_two_back_players_xpos(self): """ Find the back two players of the team. Takes into account whether team is playing right to left or left to right :return: pair of x-coordinates for the two back players :rtype: tuple[float] """ # sort using `xcor()`, lowest numbers first ordered_players = sorted( self.players, key=lambda player: player.xcor() ) back_two_indices = -1 - self.end, -self.end # if self.end is -1, then indices are 0 and 1, # so this represents the first two elements # (smallest `xcor()`, therefore furthest left) # if self.end is 1, then indices are -2 and -1, # so this represents the last two elements # (largest `xcor()`, therefore furthest right) return tuple( ordered_players[idx].xcor() for idx in back_two_indices ) def find_front_player_xpos(self): """ Find the frontmost player of the team. Takes into account whether team is playing right to left or left to right :return: x-coordinate of the forward-most player :rtype: float """ # sort using `xcor()`, lowest numbers first ordered_players = sorted( self.players, key=lambda player: player.xcor() ) front_one_index = min(self.end, 0) # if self.end is -1, index is -1 so represents the # last item in list (largest `xcor()`), therefore # furthest right # if self.end is 1, index is 0 so represents the # first item in list (smallest `xcor()`), therefore # furthest left return ordered_players[front_one_index].xcor()
I’ve included detailed docstrings for these functions and comments in the code to explain the algorithms used. Docstrings are the comments within the triple quoted strings that follow immediately after the function signature which document the function.
The important first step in both methods is to sort the list of players based on their x-coordinates. You achieve this using the built-in sorted()
function with the key
parameter. The lambda
function used as the key allows sorted()
to use the players’ x-coordinates, which is returned by the Turtle
method xcor()
, to sort the players.
Therefore, ordered_players
is a list of all the players in the team ordered from left to right on the pitch. [Note: we could have used the list method sort()
on self.players
, too. I opted to create a new list in this case.]
find_two_back_players_xpos()
then calculates the indices corresponding to the two back players. These will be either 0 and 1 or -2 and -1.
0 and 1 represent the first two elements in the list. These are the two players furthest to the left on the pitch. Therefore, they are the back two players for a team playing left to right. See the comments in the code for more detail.
-2 and -1 represent the last two elements in the list. These are the players furthest to the right. Therefore, they’re the back two players for the team playing right to left.
The method returns a tuple containing both x-coordinates. You’re using a comprehension to get the value of xcor()
for the two players matching the indices you’ve just calculated.
Note that the code shown below is not a tuple comprehension:
(ordered_players[idx].xcor() for idx in back_two_indices)
The comprehension within parentheses ()
creates a generator. You’re converting this generator into a tuple using the tuple()
call in find_two_back_players_xpos()
.
find_front_player_xpos()
is similar but a bit simpler since it only needs to find one x-coordinate and it returns only one value.
Offside Or Not Offside?
And finally, you get to decide whether the action you’re dealing with is an offside position. The snapshot you’re considering is the point when the player with the ball kicks it forward. If the attacking player at the front doesn’t have at least two opponents in front of him or her, then it’s an offside position.
You can define the method is_offside()
in Action
to work out whether there’s an offside situation:
# offside.py import random import turtle class Action: pitch_size = 800, 600 pitch_colour = "forest green" def __init__(self): # ... def update(self): # ... def create_teams(self): # ... def choose_attacking_team(self): # ... def place_ball(self): # ... def is_offside(self): """ Check if scenario is offside or not :return: True if offside and False if not offside :rtype: bool """ # Check that front player is behind two back players front_player_pos = self.attacking_team.find_front_player_xpos() if self.attacking_team_indicator == -1: second_last_back_player_pos = min( self.defending_team.find_two_back_players_xpos() ) return front_player_pos > second_last_back_player_pos else: second_last_back_player_pos = max( self.defending_team.find_two_back_players_xpos() ) return front_player_pos < second_last_back_player_pos class Player(turtle.Turtle): # ... class GoalKeeper(Player): # ... class Team: # ...
This method gets the frontmost attacker’s x-position using attacking_team.find_front_player_xpos()
. What happens next depends on whether the attacking team is attacking left to right or right to left. This is determined by the data attribute attacking_team_indicator
, which is either -1 or 1.
As I mentioned earlier, you can find a “clever” solution that doesn’t need an if...else
. However, the solution used here is more readable. There’s already a lot happening in this method as it is!
If the attacking team is attacking left to right, the defending team is playing right to left. Therefore, you want the smallest of the two x-coordinates to find the position of the second-last player. The attacker who’s furthest forward needs to be behind this defender. Therefore, if the x-coordinate of the frontmost attacker is larger than that of the second-last defender, it’s an offside situation. Remember that we’re currently considering an attacking team playing left to right. The method returns True
. If the frontmost attacker’s x-coordinate is smaller than the second-last back player, the method returns False
, or not offside!
The logic is reversed for the case when the attacking team is attacking right to left. You’ll need to digest these algorithms a bit and it will make sense!
You can make a small change to offside_rule_quiz.py
to print out the value of game.is_offside()
:
# offside_rule_quiz.py import turtle from offside import Action game = Action() game.update() print(game.is_offside()) turtle.done()
When you run this script, you’ll see the snapshot of the action and either True
or False
will be printed in the output console depending on whether the situation is offside or not.
Special case 1: the frontmost attacker is the one with the ball
We need to take care of two special cases. If the frontmost attacker is the one who has the ball, then there is no offside situation. The offside rule only applies to a player who is in front of the ball. You can add a check to is_offside()
to account for this situation by checking whether the x-coordinate of the frontmost attacking player is the same as the player who has the ball and return False
(no offside) if it is.
Special case 2: the frontmost attacker is in their own half of the pitch
The offside rule doesn’t apply if the frontmost player is in his or her own half of the pitch when the ball is kicked towards them. Therefore, you can add another check to see whether the frontmost player’s x-coordinate is within the team’s own half and return False
if it is.
Here are both additions to is_offside()
:
# offside.py import random import turtle class Action: pitch_size = 800, 600 pitch_colour = "forest green" def __init__(self): # ... def update(self): # ... def create_teams(self): # ... def choose_attacking_team(self): # ... def place_ball(self): # ... def is_offside(self): """ Check if scenario is offside or not :return: True if offside and False if not offside :rtype: bool """ # Check if frontmost attacker has the ball and kicks it # (or is exactly in line with player with ball–very low probability) if self.attacking_team.find_front_player_xpos() == self.player_with_ball.xcor(): return False # Check that front player is behind two back players front_player_pos = self.attacking_team.find_front_player_xpos() if self.attacking_team_indicator == -1: second_last_back_player_pos = min( self.defending_team.find_two_back_players_xpos() ) # Is attacker in own half if front_player_pos < 0: return False return front_player_pos > second_last_back_player_pos else: second_last_back_player_pos = max( self.defending_team.find_two_back_players_xpos() ) # Is attacker in own half if front_player_pos > 0: return False return front_player_pos < second_last_back_player_pos class Player(turtle.Turtle): # ... class GoalKeeper(Player): # ... class Team: # ...
The Quiz: Finishing Touches To Turn This Into A Quiz
The main code is in place now. You can create a snapshot of an action between two teams and the program can determine whether it’s an offside situation.
There are still a few finishing touches to turn this into a quiz. You can work on these in small steps.
Label showing result in the game window
Let’s add a label to show whether there’s an offside situation directly on the screen. You can add a new Turtle
object whose job will be to write text on the screen:
# offside.py import random import turtle class Action: pitch_size = 800, 600 pitch_colour = "forest green" def __init__(self): # Pitch is the turtle Screen object # (technically _Screen object, as Screen() is a # function, but we can ignore this subtlety) self.pitch = turtle.Screen() self.pitch.tracer(0) self.pitch.setup( Action.pitch_size[0], Action.pitch_size[1], ) self.pitch.bgcolor(Action.pitch_colour) self.create_teams() self.choose_attacking_team() self.place_ball() # Label to show whether scenario is offside or not offside self.label = turtle.Turtle() self.label.penup() self.label.sety(self.pitch_size[1] // 3) self.label.hideturtle() def update(self): # ... def create_teams(self): # ... def choose_attacking_team(self): # ... def place_ball(self): # ... def is_offside(self): # ... def display_result(self, result, colour): """ Show on screen whether scenario is offside or not offside """ self.label.color(colour) self.label.write( result, font=("Courier", 30, "bold"), align="center" ) class Player(turtle.Turtle): # ... class GoalKeeper(Player): # ... class Team: # ...
There are a few new Turtle
methods you’ve not used before:
sety()
: likesetposition()
but sets only the turtle’s y-coordinatehideturtle()
: hides the turtle so that only what it draws or writes is displayed, but not the turtle itselfwrite()
: writes text on the screen. You can also use the optionalfont
andalign
arguments
You can update offside_rule_quiz.py
:
# offside_rule_quiz.py import turtle from offside import Action game = Action() game.update() if game.is_offside(): game.display_result("OFFSIDE", "red") else: game.display_result("NOT OFFSIDE", "white") turtle.done()
When you run this code, you’ll get a label in either red or white showing the outcome of the offside decision:
Text in title bar
We can add another method in Action
which simply calls the title()
method in the turtle
module:
# offside.py # ... In Action class def write_title(self, text): """Write in title bar of `turtle` window""" self.pitch.title(text)
And you can put a placeholder line in offside_rule_quiz.py
for now, which you’ll improve later:
# offside_rule_quiz.py import turtle from offside import Action game = Action() game.write_title("OFFSIDE RULE GAME | Test 1 | Points: 0") game.update() if game.is_offside(): game.display_result("OFFSIDE", "red") else: game.display_result("NOT OFFSIDE", "white") turtle.done()
The window now has the text you chose in the title bar:
Dialog to get user input
We need the player of the Offside Rule Python Game to be able to input whether they think the scenario presented is offside. The turtle
module has a textinput()
method which displays a dialog box and waits for the user input. You can write a method in the Action
class to use textinput()
:
# offside.py # ... In Action class def register_user_input(self): """ Get user to input whether scenario is offside or not """ self.user_response = self.pitch.textinput( "Is this offside?", "Type Y for offside and N for not offside" ).lower()[0]
textinput()
required two arguments:
- The text in the title bar of the dialog window
- The prompt to show the user
It returns a string with whatever the user typed in the dialog box. You’re making the output a bit more robust by changing to lowercase and fetching the first element of the string. This means that “Yes”, “yes”, “Y”, and “y” all return "y"
. (And so does “Yeti”!)
You’re storing the result in a new data attribute called user_response
. You can call this method in offside_rule_quiz.py
:
# offside_rule_quiz.py import turtle from offside import Action game = Action() game.write_title("OFFSIDE RULE GAME | Test 1 | Points: 0") game.update() game.register_user_input() if game.is_offside(): game.display_result("OFFSIDE", "red") else: game.display_result("NOT OFFSIDE", "white") turtle.done()
When you run the code, you’re shown a dialog to enter your user input:
However, the program doesn’t take the user input into account for now. Next, let’s assign points if you get the offside call right.
Points tally
Here are some changes to offside_rule_quiz.py
:
# offside_rule_quiz.py import turtle from offside import Action points = 0 game = Action() game.write_title(f"OFFSIDE RULE GAME | Test 1 | Points: {points}") game.update() game.register_user_input() if game.is_offside(): if game.user_response == "y": points += 1 game.display_result("OFFSIDE", "red") else: # Not Offside if game.user_response == "n": points += 1 game.display_result("NOT OFFSIDE", "white") game.write_title(f"OFFSIDE RULE GAME | Test 1 | Points: {points}") turtle.done()
You’ll see the points change in the title bar after you answer, assuming you get the decision right, of course!
Rather than accessing the data attribute user_response
directly and checking for equality with "y"
or "n"
in offside_rule_quiz.py
, you could create another method in the Action
class in offside.py
to check the user response and return a Boolean. This would probably be a neater solution, as you’re avoiding the need for the user of the class to access the instance variable. I’ll leave this as an exercise for you!
Repeat tests
Finally, you can run the test several times rather than just once. You’ll add the points each time the player gets the offside decision right. You need one last method in Action
to clear the screen and tidy up before the next test:
# offside.py # ... In Action class def clear(self): """Clear all turtles from the screen and memory""" self.pitch.clear()
You’re now ready to finish the quiz:
# offside_rule_quiz.py import turtle import time from offside import Action number_of_tests = 10 points = 0 for test in range(1, number_of_tests + 1): game = Action() game.write_title(f"OFFSIDE RULE GAME | Test {test} | Points: {points}") game.update() game.register_user_input() if game.is_offside(): if game.user_response == "y": points += 1 game.display_result("OFFSIDE", "red") else: # Not Offside if game.user_response == "n": points += 1 game.display_result("NOT OFFSIDE", "white") if test == number_of_tests: break for countdown in range(5, 0, -1): game.pitch.title( f"OFFSIDE RULE GAME | Test {test} | Points: {points} | Next test in {countdown}...") game.update() time.sleep(1) game.clear() game.write_title(f"You've scored {points} out of {number_of_tests}") turtle.done()
Offside Rule Quiz in Python
And that brings us to an end. Here’s a video of what the game looks like in this final version:
The final full versions of both offside.py
and offside_rule_quiz.py
are in an appendix at the end.
All that’s left is to ensure you understand the offside rule perfectly!
Appendix: Final Version of Code For The Offside Rule Quiz in Python
The classes are defined in offside.py
:
# offside.py import random import turtle class Action: pitch_size = 800, 600 pitch_colour = "forest green" def __init__(self): # Pitch is the turtle Screen object # (technically _Screen object, as Screen() is a # function, but we can ignore this subtlety) self.pitch = turtle.Screen() self.pitch.tracer(0) self.pitch.setup( Action.pitch_size[0], Action.pitch_size[1], ) self.pitch.bgcolor(Action.pitch_colour) self.create_teams() self.choose_attacking_team() self.place_ball() # Label to show whether scenario is offside or not offside self.label = turtle.Turtle() self.label.penup() self.label.sety(self.pitch_size[1] // 3) self.label.hideturtle() def update(self): self.pitch.update() def create_teams(self): """Create two teams facing opposite directions""" self.left_to_right_team = Team( "orange", "dark salmon", -1 ) self.right_to_left_team = Team( "light blue", "dark blue", 1 ) def choose_attacking_team(self): """Pick which team is attacking in this action""" self.attacking_team_indicator = random.choice([-1, 1]) if self.attacking_team_indicator == -1: self.attacking_team = self.left_to_right_team self.defending_team = self.right_to_left_team else: self.attacking_team = self.right_to_left_team self.defending_team = self.left_to_right_team self.attacking_direction = ( 90 + 90 * self.attacking_team_indicator ) def place_ball(self): """ Assign ball to one of the players in the attacking team """ self.player_with_ball = random.choice( self.attacking_team.players ) ball = turtle.Turtle() ball.penup() ball.shape("circle") ball.color("white") ball.setposition(self.player_with_ball.position()) ball.setheading(self.attacking_direction) ball.forward(20) def is_offside(self): """ Check if scenario is offside or not :return: True if offside and False if not offside :rtype: bool """ # Check if frontmost attacker has the ball and kicks it # (or is exactly in line with player with ball–very low probability) if self.attacking_team.find_front_player_xpos() == self.player_with_ball.xcor(): return False # Check that front player is behind two back players front_player_pos = self.attacking_team.find_front_player_xpos() if self.attacking_team_indicator == -1: second_last_back_player_pos = min( self.defending_team.find_two_back_players_xpos() ) # Is attacker in own half if front_player_pos < 0: return False return front_player_pos > second_last_back_player_pos else: second_last_back_player_pos = max( self.defending_team.find_two_back_players_xpos() ) # Is attacker in own half if front_player_pos > 0: return False return front_player_pos < second_last_back_player_pos def display_result(self, result, colour): """ Show on screen whether scenario is offside or not offside """ self.label.color(colour) self.label.write( result, font=("Courier", 30, "bold"), align="center" ) def write_title(self, text): """Write in title bar of `turtle` window""" self.pitch.title(text) def register_user_input(self): """ Get user to input whether scenario is offside or not """ self.user_response = self.pitch.textinput( "Is this offside?", "Type Y for offside and N for not offside" ).lower()[0] def clear(self): """Clear all turtles from the screen and memory""" self.pitch.clear() class Player(turtle.Turtle): def __init__(self, team, colour, direction): super().__init__() # Turtle methods self.penup() self.setheading(direction) self.color(colour) self.shape("triangle") # Attributes/Methods specific to Player self.team = team self.set_bounds() self.place_on_pitch() def set_bounds(self): """ Set the left, right, top, and bottom limits where a player can be located on the pitch. Leave a boundary at the edge of the pitch to avoid players being partially off the pitch """ pitch_half_width = Action.pitch_size[0] // 2 pitch_half_height = Action.pitch_size[1] // 2 self.left_bound = -int(pitch_half_width * 0.95) self.right_bound = int(pitch_half_width * 0.95) self.bottom_bound = -int(pitch_half_height * 0.95) self.top_bound = int(pitch_half_height * 0.95) def place_on_pitch(self): """Place player in a random position on the pitch""" self.setposition( random.randint(self.left_bound, self.right_bound), random.randint(self.bottom_bound, self.top_bound), ) class GoalKeeper(Player): def __init__(self, team, colour, direction): super().__init__(team, colour, direction) def set_bounds(self): """ Set the left, right, top, and bottom limits where a goalkeeper can be located on the pitch. Goalkeeper is located close to own goal """ pitch_half_width = Action.pitch_size[0] // 2 pitch_half_height = Action.pitch_size[1] // 2 self.left_bound, self.right_bound = sorted( [ self.team.end * pitch_half_width * 0.98, self.team.end * pitch_half_width * 0.85, ] ) self.bottom_bound = -pitch_half_height * 0.5 self.top_bound = pitch_half_height * 0.5 class Team: def __init__(self, player_colour, keeper_colour, end): self.player_colour = player_colour self.keeper_colour = keeper_colour self.end = end # -1 if team playing left to right # 1 if team playing right to left self.players = [] self.direction = 90 + 90 * self.end self.create_team() def create_team(self): self.players.append( GoalKeeper(self, self.keeper_colour, self.direction) ) for _ in range(10): self.players.append( Player(self, self.player_colour, self.direction) ) def find_two_back_players_xpos(self): """ Find the back two players of the team. Takes into account whether team is playing right to left or left to right :return: pair of x-coordinates for the two back players :rtype: tuple[float] """ # sort using `xcor()`, lowest numbers first ordered_players = sorted( self.players, key=lambda player: player.xcor() ) back_two_indices = -1 - self.end, -self.end # if self.end is -1, then indices are 0 and 1, # so this represents the first two elements # (smallest `xcor()`, therefore furthest left) # if self.end is 1, then indices are -2 and -1, # so this represents the last two elements # (largest `xcor()`, therefore furthest right) return tuple( ordered_players[idx].xcor() for idx in back_two_indices ) def find_front_player_xpos(self): """ Find the frontmost player of the team. Takes into account whether team is playing right to left or left to right :return: x-coordinate of the forward-most player :rtype: float """ # sort using `xcor()`, lowest numbers first ordered_players = sorted( self.players, key=lambda player: player.xcor() ) front_one_index = min(self.end, 0) # if self.end is -1, index is -1 so represents the # last item in list (largest `xcor()`), therefore # furthest right # if self.end is 1, index is 0 so represents the # first item in list (smallest `xcor()`), therefore # furthest left return ordered_players[front_one_index].xcor()
The quiz is in offside_rule_quiz.py
:
# offside_rule_quiz.py import turtle import time from offside import Action number_of_tests = 10 points = 0 for test in range(1, number_of_tests + 1): game = Action() game.write_title(f"OFFSIDE RULE GAME | Test {test} | Points: {points}") game.update() game.register_user_input() if game.is_offside(): if game.user_response == "y": points += 1 game.display_result("OFFSIDE", "red") else: # Not Offside if game.user_response == "n": points += 1 game.display_result("NOT OFFSIDE", "white") if test == number_of_tests: break for countdown in range(5, 0, -1): game.pitch.title( f"OFFSIDE RULE GAME | Test {test} | Points: {points} | Next test in {countdown}...") game.update() time.sleep(1) game.clear() game.write_title(f"You've scored {points} out of {number_of_tests}") turtle.done()
Get the latest blog updates
No spam promise. You’ll get an email when a new blog post is published
The post Write A Football Offside Rule Quiz in Python While Practising Object-Oriented Programming appeared first on The Python Coding Book.