All News & Analysis

SkillCorner Open Data #4: Visualising Off-Ball Runs

Next in the Open Data series

In the previous entries in our SkillCorner Open Data Series, we have used aggregated metrics to chart player attributes, compare players and build archetypes. This time around we dig into event-level Game Intelligence data to look at visualising Off-Ball Runs.

As part of our open release from the 2024/25 Australian A-League, we have included 10 matches of Dynamic Events data – a rich collection of data points and contextual flags derived from our tracking data that form the backbone of our Game Intelligence suite.

The matches, and their corresponding IDs, are as follows: 

  • Auckland FC 2 - 0 Newcastle (30/11/2024): 1886347
  • Auckland FC 2 - 1 Wellington Phoenix (07/12/2024): 1899585
  • Brisbane Roar 0 - 1 Perth Glory (21/12/2024): 1925299
  • Central Coast Mariners 1 - 1 Melbourne City (31/12/2024): 1953632
  • Sydney FC 4 - 1 Adelaide United (01/02/2025): 1996435
  • Melbourne City 2 - 0 Macarthur FC (07/03/2025): 2006229
  • Wellington Phoenix 2 - 3 Melbourne Victory (12/04/2025): 2011166
  • Western United 1 - 0 Sydney FC (27/04/2025): 2013725
  • Western United 4 - 2 Auckland FC (03/05/2025): 2015213
  • Melbourne Victory 0 - 1 Auckland FC (17/05/2025): 2017461

The depth and quality of the data available in these files open up a huge number of potential analysis paths. In this particular blog, our focus will be on Off-Ball Runs (OBRs) and how to visualise them on a pitch.

1 - Load Dynamic Events and Filter to Off-Ball Runs

Our first step is to load in the Dynamic Events file for a given match. 

In this blog, we will take the 01/02/2025 match between Sydney FC and Adelaide United as our example, but you can easily select another encounter by inputting the relevant match_id (detailed above) into the URL string from which we import the file.

# Install libraries
!pip install numpy pandas mplsoccer skillcorner skillcornerviz

import os
import io
import pandas as pd
from mplsoccer import VerticalPitch
from skillcornerviz.utils import constants

# Load the Dynamic Events CSV file directly from Github Open Data

# Change the URL to reflect the desired match ID. ID 1996435 used as default
url = "https://raw.githubusercontent.com/SkillCorner/opendata/master/data/matches/1996435/1996435_dynamic_events.csv"

df_dynamic = pd.read_csv(url)
df_dynamic.head()

If you desire, you can then run the following code to get a list of the available columns within the Dynamic Events file.

#Get a list of available columns
print(list(df_dynamic.columns))

Finally, we create a new dataframe from the wider Dynamic Events file by filtering down to Off-Ball Runs.

#Filter to Off-Ball Runs
df_obr = df_dynamic[df_dynamic["event_type"] == "off_ball_run"].reset_index(drop=True)


print(f"Loaded {len(df_obr)} off-ball run events")
df_obr.head()

2 - Visualise Player Off-Ball Runs

The Dynamic Events file allows us to not only identify the count of Off-Ball Runs performed by each player but also visualise exactly where they happened on the pitch and add supplementary information such as average speed and resulting actions.

As a starting point, we can run the following code to get a list of all the players who took part in the selected match and their count of Off-Ball Runs.

# Get a list of players in the match and a count of their Off-Ball runs
player_summary = (df_obr.groupby(['player_id', 'player_name', 'team_shortname'])
                 .size()
                 .reset_index(name='run_count'))


# Sort by run count
player_summary = player_summary.sort_values('run_count', ascending=False)


print(player_summary)

From the selected match, Sydney FC’s Joe Lolley stands out as the player with the most Off-Ball Runs, so he seems a good candidate for visualisation.

We use his player_id to filter down to a new dataframe of his Off-Ball Runs:

# Change out the player_id as necessary to visualise the runs of different players
viz = df_obr[df_obr['player_id'] == 18573].copy()


And then we are ready to visualise them.

For this task, we are using the mplsoccer package’s pitch-drawing function. We will also apply a series of visual touches to highlight additional information about the runs:

  • Green Highlight: For Off-Ball Runs at high-intensity speeds, encompassing high-speed running (20 to 25 km/h) and sprinting (>25 km/h).
  • White Circle: For Off-Ball Runs that resulted in the player receiving a pass
  • Dotted Line: The trajectory of the pass that found the Off-Ball Run

The code is as follows:

# --- Set up the pitch ---
pitch = VerticalPitch(
   pitch_type='skillcorner',
   line_alpha=0.5,
   pitch_length=105,
   pitch_width=68,
   half=False, #To show full pitch; set to "True" to only visualise the attacking half                          
   pitch_color='#e8e8e6',
   line_color=constants.TEXT_COLOR,
   linewidth=1.5
)


fig, ax = pitch.grid(figheight=8, endnote_height=0.05, title_height=0.08, axis=False)


ax['title'].text(0.5, 0.5, 'J. Lolley: Off-Ball Runs\nSydney FC vs. Adelaide United | 01/02/2025',
               ha='center', va='center', fontsize=12, linespacing=1.5)


ax['endnote'].text(0.5, 0.5, 'Created using SkillCorner Open Data',
                  va='center', ha='center', fontsize=10)


# --- Draw each off-ball run ---
for i, row in viz.iterrows():


   # Highlight specific speed bands
   # Speed bands: 'running' < 'hsr' < 'sprinting'
   if row['speed_avg_band'] in ['hsr', 'sprinting']:
       line_color = constants.PRIMARY_HIGHLIGHT_COLOR
   else:
       line_color = constants.TEXT_COLOR           # ALL OTHER TYPES REMAIN


   # Draw the run
   # xy = arrowhead (start of run), xytext = arrow tail (end of run)
   # Note: VerticalPitch expects (y, x) because the pitch is rotated 90°
   ax['pitch'].annotate(
       text='',
       xy=(row['y_start'], row['x_start']),         # Arrowhead at run start
       xytext=(row['y_end'], row['x_end']),          # Tail at run end
       arrowprops=dict(
           arrowstyle="wedge,shrink_factor=0.5,tail_width=0.4",
           facecolor=line_color,
           edgecolor=line_color,
           lw=0.1,
           alpha=0.8
       ),
       zorder=1
   )


   # Draw a small dot at the end position of the run
   ax['pitch'].plot(row['y_end'], row['x_end'], marker='o', color=line_color, markersize=4)


   # Visualise received runs with a white circle
   if True: # Change this to True if you want to show reception
       if row['received'] == True:
           ax['pitch'].scatter(
               row['y_end'],
               row['x_end'],
               c='white',
               edgecolor=constants.TEXT_COLOR,
               alpha=1,
               lw=0.5,
               s=200
           )
           ax['pitch'].plot(
               [row['player_in_possession_y_end'], row['y_end']],
               [row['player_in_possession_x_end'], row['x_end']],
               c=constants.TEXT_COLOR,
               lw=0.9,
               alpha=1,
               ls='--'
           )

And the end result is:

You can also use this code as a base to change colouration and visual touches in line with variables such as run types (event_subtype) or other contextual information.

3 - Visualise Team High-Intensity Off-Ball Runs

We can also visualise Off-Ball Runs at a team level. In this case, we will take the same match as our player example and filter down to create a new dataframe that only includes Off-Ball Runs performed at high-intensity speeds (>20 km/h).

#Filter to high-intensity runs (hsr + sprinting)
# Define the speed bands you want to keep
target_bands = ['hsr', 'sprinting']


# Create the filtered dataframe
viz_teams = df_obr[df_obr['speed_avg_band'].isin(target_bands)].copy()


# Verify the filter worked
print(f"Remaining speed bands: {viz_teams['speed_avg_band'].unique()}")

We can then visualise the runs of each team side-by-side using the pitch.grid function of the mplsoccer package. For consistency, we will maintain the same visual touches as the player-level graphic even though we are only visualising high-intensity Off-Ball Runs in this case.

Here is the code:

# 1. Setup the grid with 2 columns
# Setting ncols=2 creates two side-by-side pitches
fig, ax = pitch.grid(ncols=2, figheight=8, endnote_height=0.05,
                    title_height=0.15, axis=False)


# Get the unique teams
teams = viz_teams['team_shortname'].unique()


# 2. Add Title and Endnote
ax['title'].text(0.5, 0.5, 'High-Intensity Off-Ball Runs\nSydney FC vs. Adelaide United | 01/02/2025',
               ha='center', va='center', fontsize=12, linespacing=1.5)
ax['endnote'].text(0.5, 0.5, 'Created using SkillCorner Open Data',
                  va='center', ha='center', fontsize=10)


# 3. Loop through the teams and their respective axes
for i, team in enumerate(teams):
   # Select the current axis (ax['pitch'] is now an array [0, 1])
   current_ax = ax['pitch'][i]
  
   # Filter data for just this team
   team_data = viz_teams[viz_teams['team_shortname'] == team]
  
   # Set a sub-title for each pitch
   current_ax.set_title(team, fontsize=12, pad=10)


   # --- Draw each off-ball run for this team ---
   for _, row in team_data.iterrows():
  
     # Highlight specific speed bands
     # Speed bands: 'running' < 'hsr' < 'sprinting'
     if row['speed_avg_band'] in ['hsr', 'sprinting']:
       line_color = constants.PRIMARY_HIGHLIGHT_COLOR
     else:
       line_color = constants.TEXT_COLOR


     # Draw the run on current_ax
     current_ax.annotate(
           text='',
           xy=(row['y_start'], row['x_start']),
           xytext=(row['y_end'], row['x_end']),
           arrowprops=dict(
               arrowstyle="wedge,shrink_factor=0.5,tail_width=0.4",
               facecolor=line_color, edgecolor=line_color,
               lw=0.1, alpha=0.8
           ),
           zorder=1
       )


     # Visualise received runs on current_ax
     if row['received']:
           current_ax.scatter(row['y_end'], row['x_end'], c='white',
                              edgecolor=constants.TEXT_COLOR, s=150, zorder=2)
           current_ax.plot([row['player_in_possession_y_end'], row['y_end']],
                           [row['player_in_possession_x_end'], row['x_end']],
                           c=constants.TEXT_COLOR, lw=0.9, ls='--')
          
     # Plot end dot on current_ax
     current_ax.plot(row['y_end'], row['x_end'], marker='o', color=line_color, markersize=4)

And here is the output:

Again, this is a basic starting point that can be modified to visualise different subsets of Off-Ball Runs. The pitch.grid function could also be used at a player level, for example to visualise the Off-Ball Runs of each of a team’s full-backs or forwards on a single graphic.

4 - Go Further

There are numerous additional ways to slice and visualise Off-Ball Runs. You could filter or colour by event_subtype, split by areas of the pitch (with the various channel_, third_, x_ and y_ columns) or even use the associated_off_ball_run columns to connect them to line-breaking passes and other contextual situations with the help of the Dynamic Events specification document.

The full code for this blog is available as a Google Colab notebook for easy adaptation.

We always encourage you to share your work on social media. If you do, please reference SkillCorner as the source of the data and tag our relevant account on X/Twitter or LinkedIn.

We hope you enjoyed this part of our Open Data Series. Keep an eye on our socials for upcoming entries.

Libérez la véritable valeur des données de Tracking

Réserver une démo