Analyze Corner Kick Patterns with ML

Cluster UEFA Euro 2024 corner kicks and visualize set-piece danger zones.

Introduction

30 Second Summary

Corner kicks account for a significant portion of goals in professional football. Top clubs employ performance analysts who study delivery patterns to find which types of corners create the most dangerous chances.

In this project, you will build a set-piece intelligence tool that extracts every corner kick from UEFA Euro 2024, clusters them into tactical patterns using machine learning, and produces pitch visualizations showing which delivery strategies are most effective.

What You'll Build

A Python tool that ingests corner kick event data from 51 UEFA Euro 2024 matches, engineers spatial and tactical features, applies K-Means clustering to identify distinct delivery patterns, and outputs professional pitch visualizations ranking pattern effectiveness.

By the end of this project, you'll have:

  • A working Python script that extracts and processes corner kick data from StatsBomb's open event data API.
  • A K-Means clustering model that groups corners into distinct tactical delivery patterns based on engineered spatial features.
  • Professional pitch visualizations and a printed "Corner Blueprint" summary ranking which patterns produce the most dangerous outcomes.
  • Secret Mission: Add 360 freeze-frame spatial analysis to measure defensive compactness at the moment of delivery.

Are there any prerequisites?

You need basic Python syntax knowledge (variables, loops, functions). No prior data science or football analytics experience is required.

Set Up Your Project

Before you can analyze corner kicks, you need a clean workspace with the right tools installed.

In this step, you will create a project folder, set up a Python virtual environment to keep your packages isolated, and verify that you can access StatsBomb's free football event data.

In this step, get ready to:
  • Create your project folder and virtual environment.
  • Install the analysis packages (statsbombpy, mplsoccer, pandas, scikit-learn, matplotlib).
  • Verify access to UEFA Euro 2024 data from StatsBomb.
Create your project folder and virtual environment

A virtual environment is an isolated Python workspace. Packages you install inside it stay separate from your system Python, preventing version conflicts between projects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, below Escape).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir setpiece-intel
cd setpiece-intel
  • Create and activate your virtual environment by running the commands for your operating system:

macOS

python3 -m venv venv
source venv/bin/activate

Windows

python -m venv venv
venv\Scripts\activate
  • Your terminal prompt should now show a (venv) prefix. This confirms you are working inside the virtual environment.
  • Open the setpiece-intel folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the setpiece-intel folder, and click Open.

Why open the folder this way?

Opening a folder in VS Code makes it your workspace. The Explorer sidebar shows your project files, and new terminals automatically start inside that folder.

  • VS Code may prompt you to trust the folder. Click Yes, I trust the authors.
  • Open a fresh terminal with Ctrl+` (backtick). Your virtual environment may have deactivated when VS Code reopened.
  • If you do not see the (venv) prefix, reactivate it:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate
  • Confirm your terminal shows the (venv) prefix before continuing.

Don't see (venv) in your prompt?

  • Make sure you ran the activate command for your operating system. macOS uses source venv/bin/activate while Windows uses venv\Scripts\activate.
  • On Windows, if you see a permissions error, run this command first and try again:
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned

Still stuck? Help me activate my Python virtual environment in VS Code

Install required packages

Your analysis tool depends on five packages. StatsBomb's Python library fetches the raw event data. mplsoccer draws pitch visualizations. pandas handles data manipulation. scikit-learn provides the clustering algorithm. matplotlib powers the chart output.

  • Install all five packages in one go by running this command:
pip install statsbombpy==1.18.0 mplsoccer==1.6.1 pandas scikit-learn matplotlib

What does this command do?

  • statsbombpy==1.18.0 pins the StatsBomb Python client to a specific version so the API calls behave predictably.
  • mplsoccer==1.6.1 pins the pitch-drawing library. It also installs scipy automatically as a dependency.
  • pandas, scikit-learn, and matplotlib install the latest compatible versions for data handling, clustering, and charting.

The installation may take a minute while pip downloads everything.

  • Once installation finishes, verify your packages by running:
pip list
  • You should see statsbombpy, mplsoccer, pandas, scikit-learn, and matplotlib all listed in the output.

Seeing installation errors?

  • Make sure your terminal shows the (venv) prefix. Installing without the virtual environment active can cause permission errors.
  • If you see a network timeout, check your internet connection and try running the install command again.

Need more help? Help me fix my pip install errors

Verify StatsBomb data access

StatsBomb provides free access to event data from major tournaments including UEFA Euro 2024. This quick test confirms the API connection works before you build the full analysis.

  • In VS Code's Explorer sidebar, click the New File icon (the page icon with a plus sign).
  • Name the file main.py and press Enter.
  • Add this test code to main.py:
from statsbombpy import sb

# Fetch all available competitions from StatsBomb open data
competitions = sb.competitions()
# Filter for UEFA Euro and print the results
print(competitions[competitions['competition_name'].str.contains('UEFA Euro', na=False)])

What does this code do?

  • sb.competitions() fetches a list of every competition StatsBomb makes freely available (leagues, tournaments, World Cups).
  • The second line filters that list to show only UEFA Euro entries. You are looking for the row with competition_id 55 and season_id 282, which is Euro 2024.
  • Save main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Run the script by entering this command in your terminal:

macOS

python3 main.py

Windows

python main.py
  • You should see a table of UEFA Euro competitions printed in your terminal. Look for a row showing UEFA Euro with the season 2024.

What should I see?

The output is a pandas DataFrame printed to your terminal. You are confirming that the row for Euro 2024 exists. The key values are competition_id = 55 and season_id = 282. These two IDs are how you will load match data in the next step.

Seeing a ModuleNotFoundError?

  • This means Python cannot find statsbombpy. Check that your terminal shows (venv) before running the script. If it does not, reactivate your virtual environment and try again.
  • If you are on macOS, make sure you run the script with python3 instead of python. The python command may point to an older system Python that does not have your packages.

Still stuck? Help me run my Python script with statsbombpy

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and that your terminal showed Euro 2024 in the output.

ⓧ I'd like to double check the full code

Here is the complete main.py at the end of this step:

from statsbombpy import sb

competitions = sb.competitions()
print(competitions[competitions['competition_name'].str.contains('UEFA Euro', na=False)])

Your workspace is ready and StatsBomb's data is flowing in. Next up, you will extract every corner kick from the entire Euro 2024 tournament and build tactical features from each delivery.

Extract Corner Kick Events

Your environment is ready and you have confirmed that StatsBomb's Euro 2024 data is accessible. Now it is time to pull the actual match data and isolate every corner kick from the tournament.

Raw event data contains thousands of actions per match. Your job is to filter out the corner kick deliveries, then engineer tactical features from each one that describe how, where, and how dangerously the ball was delivered.

In this step, get ready to:
  • Load all 51 Euro 2024 matches and extract every corner kick event.
  • Engineer spatial and tactical features from each delivery.
  • Link each corner to its resulting shot and xG danger score.
Load all Euro 2024 matches and extract corner events

StatsBomb stores event data per match. To get every corner kick from the tournament, you need to loop through all 51 matches and filter for passes with a pass_type of "Corner".

  • Replace the entire contents of main.py with the following code:
from statsbombpy import sb
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from mplsoccer import VerticalPitch
import matplotlib.pyplot as plt
import warnings

warnings.filterwarnings("ignore")


def load_corner_events():
    """Load all corner kick events from Euro 2024."""
    matches = sb.matches(competition_id=55, season_id=282)
    print(f"Loaded {len(matches)} matches from UEFA Euro 2024")

    all_corners = []
    all_events_by_match = {}

    for _, match in matches.iterrows():
        match_id = match["match_id"]
        events = sb.events(match_id=match_id)
        all_events_by_match[match_id] = events

        # Filter for corner kick deliveries only
        corners = events[events["pass_type"] == "Corner"].copy()
        corners["match_id"] = match_id
        all_corners.append(corners)

    corners_df = pd.concat(all_corners, ignore_index=True)
    print(f"Extracted {len(corners_df)} corner kicks")
    return corners_df, all_events_by_match


if __name__ == "__main__":
    print("Set-Piece Intelligence Tool")
    print("Analyzing UEFA Euro 2024 Corner Kicks\n")

    corners_df, all_events_by_match = load_corner_events()

What does this code do?

The imports at the top bring in all the libraries you will need across this project. The warnings.filterwarnings("ignore") line suppresses noisy deprecation warnings from the data libraries.

The load_corner_events() function fetches all match IDs for Euro 2024, then loops through each match pulling its full event data. It filters events where pass_type == "Corner" to isolate corner kick deliveries.

It returns two things: a DataFrame of all corners, and a dictionary mapping each match ID to its full event data. You will use that dictionary later to link corners to resulting shots.

  • Save main.py.
  • Run the script by running:
python main.py
  • You should see output showing 51 matches loaded and a corner kick count between 400 and 600.

This takes a minute

The script fetches event data for each match individually from the StatsBomb API. Expect it to take 1-3 minutes depending on your internet speed. You will see the output once all 51 matches have been processed.

Script hanging or timing out?

Make sure your internet connection is stable. The script makes over 50 HTTP requests to fetch match data. If it fails partway through, re-run the script.

Help me debug my StatsBomb data loading script.

Engineer corner delivery features

Raw corner events contain coordinates and metadata, but they are not ready for machine learning. You need to transform them into a structured feature set that captures the tactical intent of each delivery.

  • In main.py, add the following engineer_features() function below the load_corner_events() function (above the if __name__ block):
def engineer_features(corners_df, all_events_by_match):
    """Engineer tactical features from corner kick data."""
    features = []

    for _, corner in corners_df.iterrows():
        loc = corner["location"]
        end_loc = corner["pass_end_location"]

        if loc is None or end_loc is None:
            continue
        if not isinstance(loc, list) or not isinstance(end_loc, list):
            continue
        if len(loc) < 2 or len(end_loc) < 2:
            continue

        start_x, start_y = loc[0], loc[1]
        end_x, end_y = end_loc[0], end_loc[1]

        # Euclidean distance from corner flag to delivery target
        delivery_length = np.sqrt((end_x - start_x) ** 2 + (end_y - start_y) ** 2)

        # Classify delivery zone based on where the ball lands
        if end_y < 30:
            delivery_zone = "near_post"
        elif end_y > 50:
            delivery_zone = "far_post"
        else:
            delivery_zone = "central"

        # Delivery type flags
        is_high = 1 if corner.get("pass_height") == "High Pass" else 0
        is_inswinging = 1 if corner.get("pass_inswinging") is True else 0
        is_outswinging = 1 if corner.get("pass_outswinging") is True else 0
        is_cross = 1 if corner.get("pass_cross") is True else 0

        # Pass outcome: NaN means successful in StatsBomb data
        outcome = corner.get("pass_outcome")
        is_complete = 1 if pd.isna(outcome) else 0

        # Link to resulting shots via possession number
        match_id = corner["match_id"]
        possession_num = corner.get("possession")
        match_events = all_events_by_match[match_id]

        xg = 0.0
        led_to_shot = 0
        possession_events = match_events[match_events["possession"] == possession_num]
        shots = possession_events[possession_events["type"] == "Shot"]
        if len(shots) > 0:
            led_to_shot = 1
            xg = shots["shot_statsbomb_xg"].max()
            if pd.isna(xg):
                xg = 0.0

        features.append(
            {
                "match_id": match_id,
                "team": corner.get("team"),
                "player": corner.get("player"),
                "start_x": start_x,
                "start_y": start_y,
                "end_x": end_x,
                "end_y": end_y,
                "delivery_length": delivery_length,
                "delivery_zone": delivery_zone,
                "is_high": is_high,
                "is_inswinging": is_inswinging,
                "is_outswinging": is_outswinging,
                "is_cross": is_cross,
                "is_complete": is_complete,
                "led_to_shot": led_to_shot,
                "xg": xg,
                "event_id": corner.get("id"),
                "possession": possession_num,
            }
        )

    features_df = pd.DataFrame(features)
    print(f"Engineered features for {len(features_df)} corners")
    print(
        f"  Corners leading to a shot: {features_df['led_to_shot'].sum()} "
        f"({features_df['led_to_shot'].mean() * 100:.1f}%)"
    )
    print(f"  Average xG per corner: {features_df['xg'].mean():.4f}")
    return features_df

What does this function do?

This function transforms raw corner events into a structured feature set. For each corner, it extracts the start and end coordinates from StatsBomb's 120x80 yard coordinate system.

  • delivery_zone classifies where the ball lands: near post (y < 30), central (30-50), or far post (y > 50).
  • delivery_length measures the Euclidean distance from the corner flag to the delivery target.
  • is_inswinging and is_outswinging capture the curl direction, which affects how attackers attack the ball.
  • is_complete uses StatsBomb's convention where a NaN pass_outcome means the pass was successful.

How does the xG linking work?

Every event in StatsBomb data belongs to a possession sequence. If a shot occurs in the same possession as the corner, it means the corner delivery led to that shot attempt.

The function captures the highest shot_statsbomb_xg value from any shot in that possession. Corners that did not produce a shot get an xG of 0. This gives you a danger metric for each delivery pattern.

Link corners to resulting shots and run the full extraction

The xG linking logic is already built into engineer_features(). Now you need to wire it up in the main block so the script calls both functions in sequence.

  • In main.py, find the if __name__ == "__main__": block at the bottom of the file.
  • Find this line:
    corners_df, all_events_by_match = load_corner_events()
  • Add the following line directly below it:
    features_df = engineer_features(corners_df, all_events_by_match)
  • Save main.py and run the script again:
python main.py
  • You should see the corner count, followed by the engineered feature count, the percentage of corners that led to a shot, and the average xG per corner.

What should I see?

Your terminal output should show something like:

  • A corner count between 400 and 600 (the exact number depends on the tournament's corners).
  • A shot rate showing what percentage of corners produced a shot attempt.
  • An average xG per corner. This will be a small number (typically below 0.05) since most corners do not directly lead to shots.

Seeing a KeyError or empty DataFrame?

Make sure you added the engineer_features() function above the if __name__ block, not inside it. Also confirm the function call passes both corners_df and all_events_by_match as arguments.

Help me debug my corner feature engineering function.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

from statsbombpy import sb
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from mplsoccer import VerticalPitch
import matplotlib.pyplot as plt
import warnings

warnings.filterwarnings("ignore")


def load_corner_events():
    """Load all corner kick events from Euro 2024."""
    matches = sb.matches(competition_id=55, season_id=282)
    print(f"Loaded {len(matches)} matches from UEFA Euro 2024")

    all_corners = []
    all_events_by_match = {}

    for _, match in matches.iterrows():
        match_id = match["match_id"]
        events = sb.events(match_id=match_id)
        all_events_by_match[match_id] = events

        corners = events[events["pass_type"] == "Corner"].copy()
        corners["match_id"] = match_id
        all_corners.append(corners)

    corners_df = pd.concat(all_corners, ignore_index=True)
    print(f"Extracted {len(corners_df)} corner kicks")
    return corners_df, all_events_by_match


def engineer_features(corners_df, all_events_by_match):
    """Engineer tactical features from corner kick data."""
    features = []

    for _, corner in corners_df.iterrows():
        loc = corner["location"]
        end_loc = corner["pass_end_location"]

        if loc is None or end_loc is None:
            continue
        if not isinstance(loc, list) or not isinstance(end_loc, list):
            continue
        if len(loc) < 2 or len(end_loc) < 2:
            continue

        start_x, start_y = loc[0], loc[1]
        end_x, end_y = end_loc[0], end_loc[1]

        delivery_length = np.sqrt((end_x - start_x) ** 2 + (end_y - start_y) ** 2)

        if end_y < 30:
            delivery_zone = "near_post"
        elif end_y > 50:
            delivery_zone = "far_post"
        else:
            delivery_zone = "central"

        is_high = 1 if corner.get("pass_height") == "High Pass" else 0
        is_inswinging = 1 if corner.get("pass_inswinging") is True else 0
        is_outswinging = 1 if corner.get("pass_outswinging") is True else 0
        is_cross = 1 if corner.get("pass_cross") is True else 0

        outcome = corner.get("pass_outcome")
        is_complete = 1 if pd.isna(outcome) else 0

        match_id = corner["match_id"]
        possession_num = corner.get("possession")
        match_events = all_events_by_match[match_id]

        xg = 0.0
        led_to_shot = 0
        possession_events = match_events[match_events["possession"] == possession_num]
        shots = possession_events[possession_events["type"] == "Shot"]
        if len(shots) > 0:
            led_to_shot = 1
            xg = shots["shot_statsbomb_xg"].max()
            if pd.isna(xg):
                xg = 0.0

        features.append(
            {
                "match_id": match_id,
                "team": corner.get("team"),
                "player": corner.get("player"),
                "start_x": start_x,
                "start_y": start_y,
                "end_x": end_x,
                "end_y": end_y,
                "delivery_length": delivery_length,
                "delivery_zone": delivery_zone,
                "is_high": is_high,
                "is_inswinging": is_inswinging,
                "is_outswinging": is_outswinging,
                "is_cross": is_cross,
                "is_complete": is_complete,
                "led_to_shot": led_to_shot,
                "xg": xg,
                "event_id": corner.get("id"),
                "possession": possession_num,
            }
        )

    features_df = pd.DataFrame(features)
    print(f"Engineered features for {len(features_df)} corners")
    print(
        f"  Corners leading to a shot: {features_df['led_to_shot'].sum()} "
        f"({features_df['led_to_shot'].mean() * 100:.1f}%)"
    )
    print(f"  Average xG per corner: {features_df['xg'].mean():.4f}")
    return features_df


if __name__ == "__main__":
    print("Set-Piece Intelligence Tool")
    print("Analyzing UEFA Euro 2024 Corner Kicks\n")

    corners_df, all_events_by_match = load_corner_events()
    features_df = engineer_features(corners_df, all_events_by_match)

You now have a structured dataset where every corner kick has tactical features and a danger score. Next, you will feed these features into a K-Means clustering model to discover which delivery patterns exist across the tournament.

Cluster Corner Patterns with K-Means

Your feature-engineered DataFrame now holds the spatial and tactical DNA of every corner kick from the tournament. The next question is: are there distinct delivery patterns hiding inside those hundreds of corners?

In this step, you will use K-Means clustering to group corners into pattern types based on where they land, how they are delivered, and what type of service they use. The algorithm finds natural groupings so you can compare which tactical pattern produces the most dangerous chances.

In this step, get ready to:
  • Prepare a feature matrix and standardize it for clustering.
  • Fit K-Means with different cluster counts and assign pattern labels.
Prepare the feature matrix for clustering

K-Means measures distances between data points to form groups. If one feature has a much larger numeric range than the others, it dominates the distance calculation and the other features barely matter.

Standardizing scales every feature to have a mean of 0 and a standard deviation of 1, giving each column equal influence on the clustering.

  • In main.py, find the end of the engineer_features() function.
  • Below it, add the start of the cluster_corners() function by pasting this code:
def cluster_corners(features_df):
    """Cluster corner kicks using K-Means."""
    # Select spatial and tactical features for clustering
    cluster_features = ["end_x", "end_y", "delivery_length", "is_high", "is_inswinging", "is_outswinging"]
    X = features_df[cluster_features].copy()
    X = X.fillna(0)

    # Standardize so no single feature dominates distance calculations
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

What does this code do?

  • cluster_features picks the six columns that describe where and how each corner is delivered. Categorical text like delivery_zone is excluded because K-Means needs numeric inputs.
  • fillna(0) replaces any missing values with 0 so the model does not crash on NaN entries.
  • StandardScaler() transforms each column to have mean 0 and standard deviation 1. Without this, delivery_length (values like 20-40) would overpower is_high (values 0 or 1).
Fit K-Means and determine optimal clusters

Choosing how many clusters to use is a key decision. The elbow method runs K-Means for several values of k and records the inertia (sum of squared distances from each point to its cluster center) for each.

You look for the "elbow" where adding more clusters stops producing meaningful improvement. For corner kick patterns, 4 clusters typically maps well to tactical reality: inswing near post, outswing far post, driven low, and short corners.

  • Directly below the X_scaled = scaler.fit_transform(X) line you just added, paste the rest of the cluster_corners() function:
    # Test different cluster counts to find the elbow
    inertias = []
    k_range = range(3, 8)
    for k in k_range:
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        km.fit(X_scaled)
        inertias.append(km.inertia_)

    # Use 4 clusters based on elbow analysis
    optimal_k = 4
    print(f"\nUsing k={optimal_k} clusters")

    # Fit the final model and assign cluster labels
    km_final = KMeans(n_clusters=optimal_k, random_state=42, n_init=10)
    features_df["cluster"] = km_final.fit_predict(X_scaled)

    # Print summary statistics per cluster
    print("\nCluster Summary:")
    for c in range(optimal_k):
        cluster_data = features_df[features_df["cluster"] == c]
        print(
            f"  Cluster {c}: {len(cluster_data)} corners, "
            f"shot rate: {cluster_data['led_to_shot'].mean() * 100:.1f}%, "
            f"avg xG: {cluster_data['xg'].mean():.4f}"
        )

    return features_df, km_final, scaler

What does this code do?

  • The loop fits K-Means for k=3 through k=7 and stores inertia_ for each. Inertia is the sum of squared distances from each sample to its closest cluster center.
  • optimal_k = 4 selects 4 clusters after the elbow analysis. This matches common tactical categories of corner delivery.
  • fit_predict() trains the final model and assigns a cluster label (0, 1, 2, or 3) to each corner in the DataFrame.
  • The summary loop prints how many corners fall in each cluster, what percentage led to a shot, and the average xG. This tells you immediately which pattern is most dangerous.

Now update the __main__ block so the script calls your new function.

  • Scroll to the bottom of main.py and find this line:
    features_df = engineer_features(corners_df, all_events_by_match)
  • Add the following line directly below it:
    features_df, model, scaler = cluster_corners(features_df)
  • Save main.py.
  • Run the script in your terminal by running:
python main.py

You should see the cluster summary printed at the end of the output, showing 4 clusters with different corner counts, shot rates, and average xG values.

Not seeing the cluster summary?

  • Check that the cluster_corners() function is defined at module level (no extra indentation), not accidentally nested inside engineer_features().
  • Confirm the __main__ block line reads features_df, model, scaler = cluster_corners(features_df) with all three return values unpacked.
  • If you see a KeyError, make sure the column names in cluster_features match exactly: end_x, end_y, delivery_length, is_high, is_inswinging, is_outswinging.

help me debug my cluster_corners function

Why K-Means over other clustering methods?

K-Means produces clean, numbered groups that map directly to tactical language a coach understands. Alternatives like DBSCAN find arbitrary-shaped clusters but require density tuning and can leave points unassigned.

For set-piece analysis, you want every corner assigned to a pattern type. K-Means guarantees this while keeping the output interpretable.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and your cluster summary prints cleanly.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point in the project:

from statsbombpy import sb
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from mplsoccer import VerticalPitch
import matplotlib.pyplot as plt
import warnings

warnings.filterwarnings("ignore")


def load_corner_events():
    """Load all corner kick events from Euro 2024."""
    matches = sb.matches(competition_id=55, season_id=282)
    print(f"Loaded {len(matches)} matches from UEFA Euro 2024")

    all_corners = []
    all_events_by_match = {}

    for _, match in matches.iterrows():
        match_id = match["match_id"]
        events = sb.events(match_id=match_id)
        all_events_by_match[match_id] = events

        corners = events[events["pass_type"] == "Corner"].copy()
        corners["match_id"] = match_id
        all_corners.append(corners)

    corners_df = pd.concat(all_corners, ignore_index=True)
    print(f"Extracted {len(corners_df)} corner kicks")
    return corners_df, all_events_by_match


def engineer_features(corners_df, all_events_by_match):
    """Engineer tactical features from corner kick data."""
    features = []

    for _, corner in corners_df.iterrows():
        loc = corner["location"]
        end_loc = corner["pass_end_location"]

        if loc is None or end_loc is None:
            continue
        if not isinstance(loc, list) or not isinstance(end_loc, list):
            continue
        if len(loc) < 2 or len(end_loc) < 2:
            continue

        start_x, start_y = loc[0], loc[1]
        end_x, end_y = end_loc[0], end_loc[1]

        delivery_length = np.sqrt((end_x - start_x) ** 2 + (end_y - start_y) ** 2)

        if end_y < 30:
            delivery_zone = "near_post"
        elif end_y > 50:
            delivery_zone = "far_post"
        else:
            delivery_zone = "central"

        is_high = 1 if corner.get("pass_height") == "High Pass" else 0
        is_inswinging = 1 if corner.get("pass_inswinging") is True else 0
        is_outswinging = 1 if corner.get("pass_outswinging") is True else 0
        is_cross = 1 if corner.get("pass_cross") is True else 0

        outcome = corner.get("pass_outcome")
        is_complete = 1 if pd.isna(outcome) else 0

        match_id = corner["match_id"]
        possession_num = corner.get("possession")
        match_events = all_events_by_match[match_id]

        xg = 0.0
        led_to_shot = 0
        possession_events = match_events[match_events["possession"] == possession_num]
        shots = possession_events[possession_events["type"] == "Shot"]
        if len(shots) > 0:
            led_to_shot = 1
            xg = shots["shot_statsbomb_xg"].max()
            if pd.isna(xg):
                xg = 0.0

        features.append(
            {
                "match_id": match_id,
                "team": corner.get("team"),
                "player": corner.get("player"),
                "start_x": start_x,
                "start_y": start_y,
                "end_x": end_x,
                "end_y": end_y,
                "delivery_length": delivery_length,
                "delivery_zone": delivery_zone,
                "is_high": is_high,
                "is_inswinging": is_inswinging,
                "is_outswinging": is_outswinging,
                "is_cross": is_cross,
                "is_complete": is_complete,
                "led_to_shot": led_to_shot,
                "xg": xg,
                "event_id": corner.get("id"),
                "possession": possession_num,
            }
        )

    features_df = pd.DataFrame(features)
    print(f"Engineered features for {len(features_df)} corners")
    print(
        f"  Corners leading to a shot: {features_df['led_to_shot'].sum()} "
        f"({features_df['led_to_shot'].mean() * 100:.1f}%)"
    )
    print(f"  Average xG per corner: {features_df['xg'].mean():.4f}")
    return features_df


def cluster_corners(features_df):
    """Cluster corner kicks using K-Means."""
    cluster_features = ["end_x", "end_y", "delivery_length", "is_high", "is_inswinging", "is_outswinging"]
    X = features_df[cluster_features].copy()
    X = X.fillna(0)

    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

    inertias = []
    k_range = range(3, 8)
    for k in k_range:
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        km.fit(X_scaled)
        inertias.append(km.inertia_)

    optimal_k = 4
    print(f"\nUsing k={optimal_k} clusters")

    km_final = KMeans(n_clusters=optimal_k, random_state=42, n_init=10)
    features_df["cluster"] = km_final.fit_predict(X_scaled)

    print("\nCluster Summary:")
    for c in range(optimal_k):
        cluster_data = features_df[features_df["cluster"] == c]
        print(
            f"  Cluster {c}: {len(cluster_data)} corners, "
            f"shot rate: {cluster_data['led_to_shot'].mean() * 100:.1f}%, "
            f"avg xG: {cluster_data['xg'].mean():.4f}"
        )

    return features_df, km_final, scaler


if __name__ == "__main__":
    print("Set-Piece Intelligence Tool")
    print("Analyzing UEFA Euro 2024 Corner Kicks\n")

    corners_df, all_events_by_match = load_corner_events()
    features_df = engineer_features(corners_df, all_events_by_match)
    features_df, model, scaler = cluster_corners(features_df)

Your corners are now grouped into distinct tactical patterns, each with a measurable danger level. Next up, you will plot these clusters on a pitch to see exactly where each pattern lands.

Visualize Corner Intelligence

Your clustering model has grouped every corner kick into one of four tactical patterns. Numbers in a terminal are useful, but a coaching staff needs to see where these clusters land on the pitch to make tactical decisions.

In this step, you will build two visualizations that translate raw cluster data into professional-quality outputs. First, a pitch plot showing exactly where each cluster's deliveries land. Then, a "Corner Blueprint" summary that ranks which patterns produce the most dangerous chances.

In this step, get ready to:
  • Plot corner delivery end zones on a half-pitch using mplsoccer.
  • Generate a Corner Blueprint summary ranking cluster effectiveness.
Plot delivery end zones on a pitch

The mplsoccer library provides pitch-shaped canvases designed for football analytics. You will use a half-pitch view since corner deliveries only land in the attacking half.

  • In main.py, find the closing line of the cluster_corners() function (return features_df, km_final, scaler).
  • Below that function, add the following visualize_corners function:
def visualize_corners(features_df):
    """Generate pitch visualization of corner clusters."""
    pitch = VerticalPitch(half=True, pitch_color="white", line_color="#333333")
    fig, ax = pitch.draw(figsize=(10, 8))

    colors = ["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"]
    cluster_names = []

    for c in sorted(features_df["cluster"].unique()):
        cluster_data = features_df[features_df["cluster"] == c]
        avg_xg = cluster_data["xg"].mean()
        zone = cluster_data["delivery_zone"].mode().iloc[0] if len(cluster_data) > 0 else "unknown"
        cluster_names.append(f"Cluster {c} ({zone}, xG:{avg_xg:.3f})")

        pitch.scatter(
            cluster_data["end_x"],
            cluster_data["end_y"],
            ax=ax,
            color=colors[c % len(colors)],
            s=40,
            alpha=0.5,
            label=cluster_names[-1],
        )

    for c in sorted(features_df["cluster"].unique()):
        cluster_data = features_df[features_df["cluster"] == c]
        centroid_x = cluster_data["end_x"].mean()
        centroid_y = cluster_data["end_y"].mean()
        pitch.scatter(
            [centroid_x],
            [centroid_y],
            ax=ax,
            color=colors[c % len(colors)],
            s=300,
            marker="X",
            edgecolors="black",
            linewidth=1.5,
            zorder=10,
        )

    ax.legend(loc="lower left", fontsize=9)
    ax.set_title("UEFA Euro 2024 - Corner Kick Delivery Clusters", fontsize=14, fontweight="bold")
    plt.tight_layout()
    plt.savefig("corner_clusters.png", dpi=150, bbox_inches="tight")
    print("\nSaved: corner_clusters.png")
    plt.close()

What does this code do?

This function creates a half-pitch canvas using VerticalPitch(half=True) so you only see the attacking end where corners land.

  • The first loop scatters every corner's end_x and end_y on the pitch, color-coded by cluster. Each legend label includes the dominant delivery zone and average xG.
  • The second loop plots the centroid (mean position) of each cluster as a large "X" marker. This shows the average landing spot for each pattern type.
  • The plot is saved as corner_clusters.png at 150 DPI for clear, print-ready quality.
  • Save main.py. You will not run it yet because the __main__ block does not call visualize_corners() yet.
Generate the Corner Blueprint summary

A pitch plot shows spatial patterns, but coaching staff also need a concise ranking table. The blueprint function computes each cluster's shot rate, average xG, dominant swing type, and target zone, then ranks them from most to least dangerous.

  • Below the visualize_corners function you just added, paste the following generate_blueprint function:
def generate_blueprint(features_df):
    """Generate the Corner Blueprint summary."""
    print("\n" + "=" * 60)
    print("CORNER BLUEPRINT - UEFA Euro 2024")
    print("=" * 60)

    blueprint_data = []
    for c in sorted(features_df["cluster"].unique()):
        cluster_data = features_df[features_df["cluster"] == c]
        count = len(cluster_data)
        shot_rate = cluster_data["led_to_shot"].mean() * 100
        avg_xg = cluster_data["xg"].mean()
        zone = cluster_data["delivery_zone"].mode().iloc[0]

        if cluster_data["is_inswinging"].mean() > 0.5:
            swing = "Inswing"
        elif cluster_data["is_outswinging"].mean() > 0.5:
            swing = "Outswing"
        else:
            swing = "Mixed/Straight"

        height = "Crossed" if cluster_data["is_high"].mean() > 0.5 else "Driven/Short"

        blueprint_data.append(
            {
                "cluster": c,
                "count": count,
                "zone": zone,
                "swing": swing,
                "height": height,
                "shot_rate": shot_rate,
                "avg_xg": avg_xg,
            }
        )

    blueprint_df = pd.DataFrame(blueprint_data)
    blueprint_df = blueprint_df.sort_values("avg_xg", ascending=False)

    print(f"\n{'Rank':<5}{'Type':<30}{'Count':<8}{'Shot%':<8}{'Avg xG':<10}")
    print("-" * 60)
    for rank, (_, row) in enumerate(blueprint_df.iterrows(), 1):
        type_desc = f"{row['swing']} to {row['zone']} ({row['height']})"
        print(f"{rank:<5}{type_desc:<30}{row['count']:<8}{row['shot_rate']:.1f}%{'':>3}{row['avg_xg']:.4f}")

    print("\n" + "=" * 60)

    # Bar chart comparing cluster effectiveness
    fig, ax = plt.subplots(figsize=(10, 5))
    x_pos = range(len(blueprint_df))
    bars = ax.bar(
        x_pos,
        blueprint_df["avg_xg"],
        color=["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"][: len(blueprint_df)],
        edgecolor="black",
        linewidth=0.8,
    )
    ax.set_xticks(x_pos)
    labels = [
        f"{row['swing']}\n{row['zone']}" for _, row in blueprint_df.iterrows()
    ]
    ax.set_xticklabels(labels, fontsize=9)
    ax.set_ylabel("Average xG per Corner", fontsize=11)
    ax.set_title("Corner Pattern Effectiveness - UEFA Euro 2024", fontsize=13, fontweight="bold")
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    plt.tight_layout()
    plt.savefig("corner_blueprint.png", dpi=150, bbox_inches="tight")
    print("Saved: corner_blueprint.png")
    plt.close()

What does this code do?

  • For each cluster, the function determines the dominant swing type (inswing, outswing, or mixed) by checking whether more than 50% of corners in that cluster share a trait.
  • The blueprint table is sorted by average xG in descending order, so the most dangerous pattern type always appears at rank 1.
  • A bar chart is saved as corner_blueprint.png, giving a visual comparison of each pattern's effectiveness.

Now you need to call both new functions from the __main__ block so the script runs them.

  • Scroll down to the if __name__ == "__main__": block at the bottom of main.py.
  • Find this line:
    features_df, model, scaler = cluster_corners(features_df)
  • Add these two lines directly below it:
    visualize_corners(features_df)
    generate_blueprint(features_df)
  • Save main.py.
  • Run the complete script by running:
python main.py
  • You should see the full pipeline output ending with the Corner Blueprint table printed in your terminal, followed by "Saved: corner_blueprint.png".
  • Confirm both PNG files exist by running:
ls *.png
  • You should see corner_clusters.png and corner_blueprint.png listed in the output.

Not seeing the PNG files?

  • Make sure your terminal is in the setpiece-intel folder. The files save to whatever directory you run the script from.
  • If you see an error about VerticalPitch, check that the import line at the top of your file reads from mplsoccer import VerticalPitch.
  • If the script completes but the Blueprint table looks empty, confirm your cluster_corners() function from the previous step is assigning the cluster column to features_df.

Help me debug why my corner visualization isn't saving PNG files.

What should the Blueprint table look like?

The table ranks four pattern types from most to least dangerous. Each row shows a delivery type like "Inswing to central (Crossed)" alongside its count, shot percentage, and average xG value. The exact numbers depend on the live data, but you should see clear differences between cluster danger levels.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and both PNG files exist in your setpiece-intel folder.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point in the project:

from statsbombpy import sb
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from mplsoccer import VerticalPitch
import matplotlib.pyplot as plt
import warnings

warnings.filterwarnings("ignore")


def load_corner_events():
    """Load all corner kick events from Euro 2024."""
    matches = sb.matches(competition_id=55, season_id=282)
    print(f"Loaded {len(matches)} matches from UEFA Euro 2024")

    all_corners = []
    all_events_by_match = {}

    for _, match in matches.iterrows():
        match_id = match["match_id"]
        events = sb.events(match_id=match_id)
        all_events_by_match[match_id] = events

        corners = events[events["pass_type"] == "Corner"].copy()
        corners["match_id"] = match_id
        all_corners.append(corners)

    corners_df = pd.concat(all_corners, ignore_index=True)
    print(f"Extracted {len(corners_df)} corner kicks")
    return corners_df, all_events_by_match


def engineer_features(corners_df, all_events_by_match):
    """Engineer tactical features from corner kick data."""
    features = []

    for _, corner in corners_df.iterrows():
        loc = corner["location"]
        end_loc = corner["pass_end_location"]

        if loc is None or end_loc is None:
            continue
        if not isinstance(loc, list) or not isinstance(end_loc, list):
            continue
        if len(loc) < 2 or len(end_loc) < 2:
            continue

        start_x, start_y = loc[0], loc[1]
        end_x, end_y = end_loc[0], end_loc[1]

        delivery_length = np.sqrt((end_x - start_x) ** 2 + (end_y - start_y) ** 2)

        if end_y < 30:
            delivery_zone = "near_post"
        elif end_y > 50:
            delivery_zone = "far_post"
        else:
            delivery_zone = "central"

        is_high = 1 if corner.get("pass_height") == "High Pass" else 0
        is_inswinging = 1 if corner.get("pass_inswinging") is True else 0
        is_outswinging = 1 if corner.get("pass_outswinging") is True else 0
        is_cross = 1 if corner.get("pass_cross") is True else 0

        outcome = corner.get("pass_outcome")
        is_complete = 1 if pd.isna(outcome) else 0

        match_id = corner["match_id"]
        possession_num = corner.get("possession")
        match_events = all_events_by_match[match_id]

        xg = 0.0
        led_to_shot = 0
        possession_events = match_events[match_events["possession"] == possession_num]
        shots = possession_events[possession_events["type"] == "Shot"]
        if len(shots) > 0:
            led_to_shot = 1
            xg = shots["shot_statsbomb_xg"].max()
            if pd.isna(xg):
                xg = 0.0

        features.append(
            {
                "match_id": match_id,
                "team": corner.get("team"),
                "player": corner.get("player"),
                "start_x": start_x,
                "start_y": start_y,
                "end_x": end_x,
                "end_y": end_y,
                "delivery_length": delivery_length,
                "delivery_zone": delivery_zone,
                "is_high": is_high,
                "is_inswinging": is_inswinging,
                "is_outswinging": is_outswinging,
                "is_cross": is_cross,
                "is_complete": is_complete,
                "led_to_shot": led_to_shot,
                "xg": xg,
                "event_id": corner.get("id"),
                "possession": possession_num,
            }
        )

    features_df = pd.DataFrame(features)
    print(f"Engineered features for {len(features_df)} corners")
    print(
        f"  Corners leading to a shot: {features_df['led_to_shot'].sum()} "
        f"({features_df['led_to_shot'].mean() * 100:.1f}%)"
    )
    print(f"  Average xG per corner: {features_df['xg'].mean():.4f}")
    return features_df


def cluster_corners(features_df):
    """Cluster corner kicks using K-Means."""
    cluster_features = ["end_x", "end_y", "delivery_length", "is_high", "is_inswinging", "is_outswinging"]
    X = features_df[cluster_features].copy()
    X = X.fillna(0)

    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

    inertias = []
    k_range = range(3, 8)
    for k in k_range:
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        km.fit(X_scaled)
        inertias.append(km.inertia_)

    optimal_k = 4
    print(f"\nUsing k={optimal_k} clusters")

    km_final = KMeans(n_clusters=optimal_k, random_state=42, n_init=10)
    features_df["cluster"] = km_final.fit_predict(X_scaled)

    print("\nCluster Summary:")
    for c in range(optimal_k):
        cluster_data = features_df[features_df["cluster"] == c]
        print(
            f"  Cluster {c}: {len(cluster_data)} corners, "
            f"shot rate: {cluster_data['led_to_shot'].mean() * 100:.1f}%, "
            f"avg xG: {cluster_data['xg'].mean():.4f}"
        )

    return features_df, km_final, scaler


def visualize_corners(features_df):
    """Generate pitch visualization of corner clusters."""
    pitch = VerticalPitch(half=True, pitch_color="white", line_color="#333333")
    fig, ax = pitch.draw(figsize=(10, 8))

    colors = ["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"]
    cluster_names = []

    for c in sorted(features_df["cluster"].unique()):
        cluster_data = features_df[features_df["cluster"] == c]
        avg_xg = cluster_data["xg"].mean()
        zone = cluster_data["delivery_zone"].mode().iloc[0] if len(cluster_data) > 0 else "unknown"
        cluster_names.append(f"Cluster {c} ({zone}, xG:{avg_xg:.3f})")

        pitch.scatter(
            cluster_data["end_x"],
            cluster_data["end_y"],
            ax=ax,
            color=colors[c % len(colors)],
            s=40,
            alpha=0.5,
            label=cluster_names[-1],
        )

    for c in sorted(features_df["cluster"].unique()):
        cluster_data = features_df[features_df["cluster"] == c]
        centroid_x = cluster_data["end_x"].mean()
        centroid_y = cluster_data["end_y"].mean()
        pitch.scatter(
            [centroid_x],
            [centroid_y],
            ax=ax,
            color=colors[c % len(colors)],
            s=300,
            marker="X",
            edgecolors="black",
            linewidth=1.5,
            zorder=10,
        )

    ax.legend(loc="lower left", fontsize=9)
    ax.set_title("UEFA Euro 2024 - Corner Kick Delivery Clusters", fontsize=14, fontweight="bold")
    plt.tight_layout()
    plt.savefig("corner_clusters.png", dpi=150, bbox_inches="tight")
    print("\nSaved: corner_clusters.png")
    plt.close()


def generate_blueprint(features_df):
    """Generate the Corner Blueprint summary."""
    print("\n" + "=" * 60)
    print("CORNER BLUEPRINT - UEFA Euro 2024")
    print("=" * 60)

    blueprint_data = []
    for c in sorted(features_df["cluster"].unique()):
        cluster_data = features_df[features_df["cluster"] == c]
        count = len(cluster_data)
        shot_rate = cluster_data["led_to_shot"].mean() * 100
        avg_xg = cluster_data["xg"].mean()
        zone = cluster_data["delivery_zone"].mode().iloc[0]

        if cluster_data["is_inswinging"].mean() > 0.5:
            swing = "Inswing"
        elif cluster_data["is_outswinging"].mean() > 0.5:
            swing = "Outswing"
        else:
            swing = "Mixed/Straight"

        height = "Crossed" if cluster_data["is_high"].mean() > 0.5 else "Driven/Short"

        blueprint_data.append(
            {
                "cluster": c,
                "count": count,
                "zone": zone,
                "swing": swing,
                "height": height,
                "shot_rate": shot_rate,
                "avg_xg": avg_xg,
            }
        )

    blueprint_df = pd.DataFrame(blueprint_data)
    blueprint_df = blueprint_df.sort_values("avg_xg", ascending=False)

    print(f"\n{'Rank':<5}{'Type':<30}{'Count':<8}{'Shot%':<8}{'Avg xG':<10}")
    print("-" * 60)
    for rank, (_, row) in enumerate(blueprint_df.iterrows(), 1):
        type_desc = f"{row['swing']} to {row['zone']} ({row['height']})"
        print(f"{rank:<5}{type_desc:<30}{row['count']:<8}{row['shot_rate']:.1f}%{'':>3}{row['avg_xg']:.4f}")

    print("\n" + "=" * 60)

    fig, ax = plt.subplots(figsize=(10, 5))
    x_pos = range(len(blueprint_df))
    bars = ax.bar(
        x_pos,
        blueprint_df["avg_xg"],
        color=["#e41a1c", "#377eb8", "#4daf4a", "#984ea3", "#ff7f00"][: len(blueprint_df)],
        edgecolor="black",
        linewidth=0.8,
    )
    ax.set_xticks(x_pos)
    labels = [
        f"{row['swing']}\n{row['zone']}" for _, row in blueprint_df.iterrows()
    ]
    ax.set_xticklabels(labels, fontsize=9)
    ax.set_ylabel("Average xG per Corner", fontsize=11)
    ax.set_title("Corner Pattern Effectiveness - UEFA Euro 2024", fontsize=13, fontweight="bold")
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    plt.tight_layout()
    plt.savefig("corner_blueprint.png", dpi=150, bbox_inches="tight")
    print("Saved: corner_blueprint.png")
    plt.close()


if __name__ == "__main__":
    print("Set-Piece Intelligence Tool")
    print("Analyzing UEFA Euro 2024 Corner Kicks\n")

    corners_df, all_events_by_match = load_corner_events()
    features_df = engineer_features(corners_df, all_events_by_match)
    features_df, model, scaler = cluster_corners(features_df)
    visualize_corners(features_df)
    generate_blueprint(features_df)

You now have a complete set-piece intelligence pipeline that extracts, clusters, and visualizes corner kick patterns from a real international tournament. Next up, take on the Secret Mission to add defensive compactness analysis using 360 freeze-frame data.

Secret mission

Defensive Compactness from 360 Data

Your corner blueprint tells you what delivery patterns work best. But what about the defense? StatsBomb's 360 freeze-frame data captures the exact position of every visible player at the moment of delivery. Use it to measure how compact or spread the defending team is, and discover which patterns exploit which defensive shapes.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources in place, pause them to come back later, or delete them entirely.

Resources you used:

  • Local setpiece-intel project folder containing main.py and output PNG files.
  • Python virtual environment (venv folder inside setpiece-intel).
  • Installed Python packages (statsbombpy, mplsoccer, pandas, scikit-learn, matplotlib) inside the virtual environment.

Keep everything running

No action needed. Choose this if you plan to extend the analysis or build on this project in the future.

  • Your setpiece-intel folder stays on your Desktop with all output files intact.
  • Your virtual environment remains ready to reactivate whenever you return.
  • StatsBomb data is fetched fresh each time you run the script, so nothing is stored persistently.

Pause - I'll come back to this later

Deactivate your virtual environment to free up your terminal session. Your files and packages remain on disk for next time.

  • Deactivate the virtual environment by running:
deactivate
  • When you return, navigate back to setpiece-intel and reactivate with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).

Delete - I don't want to use this again

Remove all project files and the virtual environment. This deletes everything you built in this project.

  • Deactivate your virtual environment first by running:
deactivate
  • Navigate to your Desktop and delete the project folder.

macOS

cd ~/Desktop
rm -rf setpiece-intel

Windows

cd ~\Desktop
rmdir /s /q setpiece-intel
  • Confirm the folder is gone by running ls and checking that setpiece-intel no longer appears.

Nice Work!

Nice Work!

You just built a professional set-piece intelligence tool that extracts, clusters, and visualizes corner kick delivery patterns from UEFA Euro 2024. This is the exact type of analysis that performance analysts at professional football clubs produce for coaching staff.

You've learned how to:

  • Extract and engineer tactical features from raw event data using StatsBomb's open Python API, turning corner kick events from 51 matches into a structured dataset with delivery zones, swing types, and danger metrics.
  • Build a K-Means clustering model that groups corners into distinct tactical patterns and identifies which delivery types produce the highest expected goals.
  • Produce professional pitch visualizations and a ranked Corner Blueprint summary showing where corners land and which spatial patterns generate dangerous chances.
  • Secret Mission: Used 360 freeze-frame positional data to measure defensive compactness and identify when specific delivery patterns exploit spread defenses.

Ready to quiz yourself?