Offside Rule Python Quiz: silhouette of player kicking the moon

Write A Football Offside Rule Quiz in Python While Practising Object-Oriented Programming

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
  • 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 define
  • offside_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:

Window with a 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 the Player (which is also a Turtle), it doesn’t draw any lines
  • setheading(): changes the direction the Player is facing
  • color(): 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 screen
  • setposition(): changes the x- and y-coordinates of the object on the screen. This method is used in place_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/pitch
  • place_on_pitch(): places the player in a random position on the pitch, using the bounds calculated in set_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:

Image of offside rule game with a single player

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 screen
  • end: 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 left
  • players: a data attribute which starts as an empty list but which will be populated with Player instances
  • direction: a data attribute containing an angle showing the players’ orientation. The angle is 180º when end is 1 (facing to the left) and 0º when end is -1 (facing to the right)
  • create_team(): a method which creates Player instances and adds them to the players list.
    self, self.player_colour, and self.direction are passed as arguments when creating Player and they’re assigned to the parameters team, colour, and direction in Player.__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:

Pitch with both team's outfield players in the offside rule Python quiz

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:

Teams including outfield players and goalkeepers shown in random positions on the pitch

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 the Turtle
  • forward(): moves the Turtle in the direction it’s facing. The argument is the number of pixels the Turtle 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:

Both teams showing all players and the ball in the offside rule Python quiz game

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(): like setposition() but sets only the turtle’s y-coordinate
  • hideturtle(): hides the turtle so that only what it draws or writes is displayed, but not the turtle itself
  • write(): writes text on the screen. You can also use the optional font and align 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:

Offside Rule Quiz with result label

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:

Text in title bar of the Offside Rule Python Quiz

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:

Offside Rule Python Quiz with user input dialog box

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


Leave a Reply