Build a Shot-Location Plot for an NHL Team

HockeyIntermediatePython~8 min read

What you'll build

One team's shot attempts plotted on a drawn rink from real play-by-play.

One team's shot attempts plotted on a drawn rink from real play-by-play.
Data: NHL public API (api-web.nhle.com), retrieved June 2026

Where a team shoots from tells you how it tries to score. Here's where most plots of this go wrong, though: teams switch ends between periods, so the raw play-by-play has a team's shots flying at both nets, and if you plot it as-is you get a meaningless scatter on both sides of the rink. The fix — folding every shot onto one attacking end — is the tricky, satisfying part of this. I'll pull a single NHL game, isolate one team's attempts, highlight the goals, and sort out that geometry. The Colorado Avalanche (COL) are the example.

This builds directly on your first NHL API pull, where we set up the polite session we'll reuse here. The data is the NHL public API (api-web.nhle.com), retrieved June 2026.

  1. Find one finished game

    We start from the team's season schedule and pick the first game that has actually been played. The schedule endpoint returns every game, including ones not yet started, so we filter for a finished state - the API marks those OFF or FINAL.

    python
    import matplotlib.patches as mp
    import matplotlib.pyplot as plt
    import pandas as pd
    
    TEAM = "COL"
    SEASON = "20242025"
    session = sdt.polite_session(referer="https://www.nhl.com/")
    
    # 1. Find one finished game on the team's schedule.
    sched = session.get(f"https://api-web.nhle.com/v1/club-schedule-season/{TEAM}/{SEASON}",
                        timeout=30).json()
    finished = [g for g in sched["games"] if g.get("gameState") in ("OFF", "FINAL")]
    game = finished[0]
    game_id = game["id"]

    Using .get("gameState") rather than ["gameState"] is a small safety habit: if a game record is missing that field, .get returns None instead of crashing. We keep only finished games and take the first one.

  2. Pull the play-by-play and find our team's ID

    Every game has a play-by-play feed - a long list of events, each tagged with a type and, for shots, a location. We fetch it, then figure out Colorado's numeric team ID by checking whether they were the home or away side, and grab the opponent's abbreviation for our chart title.

    python
    # 2. Pull that game's play-by-play and find our team's id.
    pbp = session.get(f"https://api-web.nhle.com/v1/gamecenter/{game_id}/play-by-play",
                      timeout=30).json()
    team_id = pbp["homeTeam"]["id"] if pbp["homeTeam"]["abbrev"] == TEAM else pbp["awayTeam"]["id"]
    opp = pbp["awayTeam"]["abbrev"] if pbp["homeTeam"]["abbrev"] == TEAM else pbp["homeTeam"]["abbrev"]

    The two conditional expressions read as "if Colorado is the home team, use the home ID and the away opponent; otherwise flip it." We need the numeric team_id because the play events identify the shooting team by ID, not by abbreviation.

  3. Keep this team's shot attempts

    Now we walk every play and keep only Colorado's shot attempts. "Shot attempt" covers three event types: a shot-on-goal (the goalie had to stop it), a goal, and a missed-shot (it sailed wide). We skip everything else, skip the other team's shots, and skip any event without coordinates.

    python
    # 3. Keep this team's shot attempts (on goal, goals, and misses).
    SHOTS = {"shot-on-goal", "goal", "missed-shot"}
    rows = []
    for play in pbp["plays"]:
        if play["typeDescKey"] not in SHOTS:
            continue
        d = play.get("details", {})
        if d.get("eventOwnerTeamId") != team_id or d.get("xCoord") is None:
            continue
        x, y = d["xCoord"], d["yCoord"]
        if x < 0:                      # fold every shot onto the right-hand goal
            x, y = -x, -y
        rows.append({"x": x, "y": y, "type": play["typeDescKey"]})
    shots = pd.DataFrame(rows)

    The crucial line is the fold. The rink is measured in feet with center ice at x = 0, one net near x = +89 and the other near x = -89. Because teams trade ends each period, Colorado's shots land at both extremes. By flipping any shot with negative x - negating both x and y to rotate it 180° - we stack every attempt onto the same attacking end. Without this, your plot would look like two teams shooting at each other.

  4. Inspect the shots

    Let's see what we collected: a count of attempts, a breakdown by type, and a few rows of folded coordinates.

    python
    print(f"{TEAM} vs {opp}, game {game_id}: {len(shots)} shot attempts")
    print(shots["type"].value_counts().to_string())
    sdt.show_df(shots, n=6)
    Colorado's shot attempts, folded to one end
    COL vs DAL, game 2024010017: 43 shot attempts
    type
    shot-on-goal    28
    missed-shot     13
    goal             2
        x   y          type
    0  45   4  shot-on-goal
    1  31  -3  shot-on-goal
    2  69 -41   missed-shot
    3  51  26  shot-on-goal
    4  77  -8  shot-on-goal
    5  86  -9  shot-on-goal

    In this game Colorado vs Dallas, Colorado put up 43 shot attempts: 28 on goal, 13 that missed, and 2 goals. Look at the x values in the sample - they're all positive and bunched in the offensive zone (45, 31, 69, 51, 77, 86), which confirms the fold worked. The y values run from about -41 to +26, spreading the shots across the width of the ice. Every number here is real, pulled live from the game feed.

  5. Draw a simple rink

    A shot plot needs a rink under it for context, but we don't need a broadcast-perfect diagram - just enough landmarks to read positions: the boards (a rounded rectangle), the center and blue lines, the goal line, the net, and a couple of faceoff circles. We wrap it in a function so the drawing logic stays separate from the data.

    python
    def draw_rink(ax):
        """A simple half-rink: boards, lines, and the goal, in NHL feet coordinates."""
        ax.add_patch(mp.FancyBboxPatch((-100, -42.5), 200, 85,
                     boxstyle="round,pad=0,rounding_size=28", fill=False,
                     edgecolor="#20242B", linewidth=1.6))
        ax.axvline(0, color=sdt.SPORT_COLORS["baseball"], linewidth=2)      # center line
        ax.axvline(25, color=sdt.sport_color("hockey"), linewidth=2)        # blue line
        ax.plot([89, 89], [-36, 36], color=sdt.SPORT_COLORS["baseball"], linewidth=1.2)  # goal line
        ax.add_patch(mp.Rectangle((89, -3), 4, 6, fill=False, edgecolor="#20242B", linewidth=1.3))  # net
        for fy in (-22, 22):                                               # faceoff dots
            ax.add_patch(mp.Circle((69, fy), 15, fill=False, edgecolor="#C2B7A1", linewidth=1))
            ax.add_patch(mp.Circle((69, fy), 1, color="#C2B7A1"))

    Each landmark is just a matplotlib patch placed at its real-feet coordinate: the goal line at x = 89, the offensive blue line at x = 25, faceoff circles centered at x = 69. Because we're using genuine rink coordinates, the shots we plot next will line up with these landmarks automatically.

  6. Plot the shots, highlighting goals

    Finally we draw the rink, split the shots into goals and everything else, and plot them with different markers so the goals jump out. Regular attempts are small blue dots; goals are big stars.

    python
    fig, ax = plt.subplots(figsize=(8, 5.4))
    draw_rink(ax)
    goals = shots[shots["type"] == "goal"]
    other = shots[shots["type"] != "goal"]
    ax.scatter(other["x"], other["y"], s=70, color=sdt.sport_color("hockey"),
               alpha=0.75, edgecolor="#FBF7EE", linewidth=0.6, label="shot")
    ax.scatter(goals["x"], goals["y"], s=180, marker="*",
               color=sdt.SPORT_COLORS["basketball"], edgecolor="#20242B",
               linewidth=0.7, label="goal", zorder=5)
    ax.set_xlim(-2, 101)
    ax.set_ylim(-43, 43)
    ax.set_aspect("equal")
    ax.axis("off")
    ax.legend(loc="lower left", fontsize=9, frameon=False)
    ax.set_title(f"{TEAM} shot locations vs {opp}\n(all attempts folded to one end)")
    fig.savefig("shot_plot.png", dpi=144, bbox_inches="tight")
    Shot-location plot of Colorado's attempts in one NHL game on a simple rink diagram, with goals marked as stars and other shots as blue dots, all folded to one attacking end
    Data: NHL public API (api-web.nhle.com), retrieved June 2026

    Two finishing touches make this readable. set_aspect("equal") keeps the rink from squashing, so distances look true. And zorder=5 on the goals forces the stars to draw on top of the dots, so a goal is never hidden behind a nearby attempt. The story jumps out: most attempts cluster in front of the net and around the faceoff circles - the high-danger areas - and the two goal stars sit right where you'd expect goals to come from.

Troubleshooting

All the shots land on the wrong half of the rink

You probably folded on the wrong condition or forgot to flip y along with x. The rule is: if x < 0, set x, y = -x, -y. Negating only x mirrors the shot to the wrong side of the ice; you must rotate both to fold it correctly onto one end.

KeyError: 'details' or missing coordinates

Not every play has a details block or coordinates - stoppages and penalties don't. That's why we read with play.get("details", {}) and skip rows where d.get("xCoord") is None. If you index with square brackets instead, those events will crash the loop.

The shot plot is empty or has very few dots

Check that team_id matched correctly - if the home/away comparison picked the wrong side, you filtered out all of your own team's shots. Print pbp["homeTeam"]["abbrev"] and confirm it's either your team or the opponent, and that TEAM exactly matches the API's three-letter code.

The rink looks stretched and the circles are ovals

You're missing ax.set_aspect("equal"). Hockey coordinates span 200 feet long by 85 wide, so without an equal aspect ratio matplotlib stretches the rink to fill the figure and the faceoff circles flatten into ovals.

Challenge yourself

Right now we plot a single game. Loop over several finished games, collect all of Colorado's shots into one big DataFrame, and plot the season's shot map - the high-danger areas will sharpen into a clear hot zone. Then try sizing or coloring each dot by shot type, or computing the share of attempts that came from inside the faceoff circles. For a baseball spin on the same location-plotting idea, revisit making a pitch-location heatmap.

Get the code

Here's the complete, working script for this tutorial. It runs exactly as shown.

Download the finished script (19_build_a_shot_location_plot_for_an_nhl_team.py)

This script imports a small shared helper (and reads any bundled sample data) that live next to it in /downloads/ — grab these into the same folder so it runs as-is: sdt_common.py.

More Hockey tutorials

A team's game-by-game form as a rolling goal differential line.
Hockey Intermediate

Track a Team's Form: Rolling Goal Differential

Pull a team's full NHL schedule with scores, compute a rolling goal differential, and chart the peaks and slumps of a season - reading momentum straight from the data.

~9 min
A clean leaderboard of the season's top goal scorers.
Hockey Intermediate

Build an NHL Goal-Scoring Leaders Chart

Use the NHL's stats-leaders endpoint to pull the top goal scorers, shape the nested JSON into a tidy table, and build a leaderboard chart with each player's team.

~8 min