Passing Game Fundamentals: Routes, Timing, and Reads

The shared language of routes, the quarterback’s feet as a clock, and the one defender every pass play is built to put in a bind.

Show the Python (shared drawing helpers for this chapter)
import os
import logging

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, Patch
from matplotlib.lines import Line2D
from IPython.display import Markdown

import gridiron.play as gplay
from gridiron import Play, Player, offense, defense, routes as r, show_animation, style, draw_marker

logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
style.apply()
PDF = os.environ.get("RTG_FORMAT", "").lower() in ("pdf", "typst", "print")
FIELD_W = 160 / 3
ODD, EVEN = style.OFFENSE_DARK, style.THIRD      # outside-breaking vs inside-breaking routes


def no_numbers(ax):
    """Remove the painted yard-line numbers (they sit where receivers stand and crowd the labels)."""
    for t in list(ax.texts):
        if t.get_alpha() == 0.55:
            t.remove()


def ruler(fld, play, top=40, fs=6.5):
    """Depth labels (yards past the line of scrimmage) down the left edge of the window, so the
    faint 5-yard lines read as a scale. gridiron has no ruler, so it is drawn here."""
    x_left = FIELD_W - fld.y1 + 0.4
    for d in range(5, top + 1, 5):
        y = play.los + d
        if fld.x0 + 0.8 <= y <= fld.x1 - 0.8:
            fld.ax.text(x_left, y, f"{d}" + (" yd" if d == 5 else ""), fontsize=fs, color=style.INK_2,
                        ha="left", va="center", zorder=12,
                        bbox=dict(boxstyle="round,pad=0.12", fc=style.SURFACE, ec="none", alpha=0.85))


def tag(fld, play, text, at, ha="center", fs=7.0, ec=style.BASELINE, color=style.INK, bold=False):
    """A boxed label at (d, w) yards; `ec` colours the box edge (used to key route families)."""
    x, y = fld.xy(*play.to_abs(*at))
    fld.ax.text(float(x), float(y), text, ha=ha, va="center", fontsize=fs, color=color, zorder=12,
                fontweight="bold" if bold else "normal",
                bbox=dict(boxstyle="round,pad=0.22", fc=style.SURFACE, ec=ec, lw=0.9))


def dotted(fld, play, pts, color=style.OFFENSE, lw=1.4, arrow=True, head=9):
    """A dotted optional branch through (d, w) points, with an arrowhead."""
    xy = [tuple(float(v) for v in fld.xy(*play.to_abs(*q))) for q in pts]
    xs, ys = zip(*xy)
    fld.ax.plot(xs, ys, color=color, lw=lw, ls=(0, (1, 1.5)), zorder=4)
    if arrow:
        fld.ax.add_patch(FancyArrowPatch(xy[-2], xy[-1], arrowstyle="-|>", mutation_scale=head, color=color,
                                         lw=0, zorder=4, shrinkA=0, shrinkB=0))


def faded_route(fld, play, pts, color=style.MUTED, lw=1.6):
    """A route drawn in grey: it is on the play but is not the point of the picture."""
    xy = [tuple(float(v) for v in fld.xy(*play.to_abs(*q))) for q in pts]
    xs, ys = zip(*xy)
    fld.ax.plot(xs, ys, color=color, lw=lw, zorder=3, solid_capstyle="round")
    fld.ax.add_patch(FancyArrowPatch(xy[-2], xy[-1], arrowstyle="-|>", mutation_scale=9, color=color,
                                     lw=0, zorder=3, shrinkA=0, shrinkB=0))


def protect(p, rushers=("SDE", "SDT", "WDT", "WDE")):
    """Five linemen set and four rushers engage them: enough to say 'pass', no more."""
    for k in ("LT", "LG", "C", "RG", "RT"):
        if k in p.players:
            p.block(k, pts=[(-1.2, 0.0)])
    for k in rushers:
        if k in p.players:
            p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)


class ball_speed:
    """Temporarily set gridiron's ball speed (yd/s). Its default, 18, is an average over a whole
    flight; a firm 20-30 yard line drive to the sideline travels faster, so the timing figures use
    20-22. Used as `with ball_speed(22): ...` so other figures keep the default."""
    def __init__(self, v):
        self.v = v

    def __enter__(self):
        self.old = gplay.BALL_SPEED
        gplay.BALL_SPEED = self.v

    def __exit__(self, *a):
        gplay.BALL_SPEED = self.old


# ---------------------------------------------------------------- snapshots (print stand-in for video)
def snapshot(play, t, ax=None, lateral=(-6, 26.6), window=(-9, 20), width_in=6.5, notes=(), rings=(),
             show_ruler=False, numbers_off=True):
    """Everyone where he is at time t, faint trails of where they came from, and the ball in flight."""
    pos = {k: play.position(k, t) for k in play.players}
    lo, hi = sorted(lateral)
    moved = [pl.moved(d=float(pos[k][0]), w=float(pos[k][1])) for k, pl in play.players.items()
             if lo + 0.8 <= float(pos[k][1]) <= hi - 0.8]          # drop anyone outside the window
    q = Play([m for m in moved if m.side == "off"], [m for m in moved if m.side == "def"],
             los=play.los, ball_y=play.ball_y, to_go=play.to_go)
    q.highlights = dict(play.highlights)
    for k in rings:
        q.highlight(k)
    fld = q.draw(ax=ax, lateral=lateral, window=window, width_in=width_in, title=False)
    ax, ppy = fld.ax, fld.pts_per_yard()
    if numbers_off:
        no_numbers(ax)
    if show_ruler:
        ruler(fld, play, fs=5.5)
    P = lambda d, w: fld.xy(*play.to_abs(d, w))  # noqa: E731
    t0 = play._compile()[1]
    for k, pl in play.players.items():
        if pl.pos in ("LT", "LG", "C", "RG", "RT", "DE", "DT", "NT"):
            continue
        pts = np.array([play.position(k, tt) for tt in np.linspace(t0, t, 40)])
        if np.ptp(pts[:, 0]) + np.ptp(pts[:, 1]) < 0.3:
            continue
        xs, ys = P(pts[:, 0], pts[:, 1])
        if pl.side == "off":
            ax.plot(xs, ys, color=style.OFFENSE, lw=1.2, alpha=0.5, zorder=3)
        else:
            ax.plot(xs, ys, color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), zorder=3)
    bx, by = P(*play.ball_position(t))
    thrown = False
    for kind, _, t_rel in play.ball_events:
        if kind == "pass" and t > t_rel:
            thrown = True
            sx, sy = P(*play.ball_position(t_rel))
            ax.add_patch(FancyArrowPatch((float(sx), float(sy)), (float(bx), float(by)), arrowstyle="-|>",
                                         mutation_scale=8, color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), zorder=9))
    if not thrown and t > 0 and "QB" in play.players:
        # still in the quarterback's hands: draw it beside him (on the emptier side), not on his "Q"
        qd = play.position("QB", t)
        others = [pos[k] for k, pl in play.players.items() if pl.side == "off" and k != "QB"]
        cands = [qd + np.array([0.0, 1.3]), qd + np.array([0.0, -1.3])]
        best = max(cands, key=lambda c: min(float(np.linalg.norm(c - o)) for o in others) if others else 0)
        bx, by = P(*best)
    draw_marker(ax, float(bx), float(by), "ball", "", ppy)
    for text, at in notes:
        tag(fld, play, text, at, fs=6.3)
    return fld


def strip(plays, times, labels, lateral=(-6, 26.6), window=(-9, 20), notes=None, rings=(), width_in=6.5,
          ncols=3, legend=True, rulers=True):
    """Panels at chosen moments. `plays` is one Play (used for every panel) or one per row."""
    n = len(times)
    nrows = int(np.ceil(n / ncols))
    if isinstance(plays, Play):
        plays = [plays] * nrows
    pw = width_in / ncols
    h = nrows * (pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0]) + 0.34)
    fig, axes = plt.subplots(nrows, ncols, figsize=(width_in, h), squeeze=False)
    for ax in axes.flat[n:]:
        ax.axis("off")
    for i, (ax, t, lab) in enumerate(zip(axes.flat, times, labels)):
        snapshot(plays[i // ncols], t, ax=ax, lateral=lateral, window=window,
                 notes=(notes or {}).get(i, ()), rings=rings, show_ruler=rulers and (i % ncols == 0))
        ax.set_title(lab, fontsize=7.8, color=style.INK, loc="left")
    fig.tight_layout(h_pad=0.5, w_pad=0.3)
    if legend:
        h_ = [Line2D([], [], color=style.OFFENSE, lw=1.2, alpha=0.6, label="Receiver's path so far"),
              Line2D([], [], color=style.DEFENSE, lw=1.3, ls=(0, (3, 2)), label="Defender's path so far"),
              Line2D([], [], color=style.BALL, lw=1.4, ls=(0, (2, 1.5)), label="Ball in flight")]
        fig.legend(handles=h_, loc="upper center", bbox_to_anchor=(0.5, 0.0), ncol=3, fontsize=7.5, frameon=False)
    return fig


def show_videos(plays, titles):
    """Web build: several animations side by side in ONE output, so Quarto keeps a single figure
    (two separate show_animation calls would be split into sub-figures)."""
    import shutil
    from IPython.display import HTML, display
    from gridiron.animate import animate
    parts = []
    for pl, ttl in zip(plays, titles):
        anim = animate(pl, title=ttl, to_go=pl.to_go)
        if shutil.which("ffmpeg"):
            html = anim.to_html5_video(embed_limit=40).replace("<video ", '<video style="max-width:100%;height:auto" ')
        else:
            html = anim.to_jshtml(default_mode="once")
        plt.close(anim._fig)
        parts.append(f'<div style="flex:1 1 300px;min-width:0">{html}</div>')
    display(HTML('<div style="display:flex;gap:8px;flex-wrap:wrap">' + "".join(parts) + "</div>"))
Show the Python (load the passing data used in this chapter)
from gridiron.data import pbp, nfl

SEASONS = range(2021, 2026)          # five completed seasons, 2021-2025
PCOLS = ["season", "season_type", "game_id", "play_id", "play_type", "pass_attempt", "sack", "air_yards",
         "yards_after_catch", "receiving_yards", "complete_pass", "interception", "epa", "success",
         "passer_player_name", "receiver_player_name", "receiver_player_id", "posteam", "down", "ydstogo",
         "yards_gained"]
_raw = pbp(SEASONS, columns=PCOLS)
# Pass attempts that were actually thrown at someone: regular season, sacks out, and only rows
# with a charted air-yards value (spikes and most throwaways have none).
passes = _raw[(_raw.season_type == "REG") & (_raw.play_type == "pass") & (_raw.pass_attempt == 1)
              & (_raw.sack == 0) & _raw.air_yards.notna()].copy()
DEPTH_BINS = [-100, -0.5, 4.5, 9.5, 14.5, 19.5, 29.5, 100]
DEPTH_LABS = ["Behind\nLOS", "0-4", "5-9", "10-14", "15-19", "20-29", "30+"]
passes["depth"] = pd.cut(passes.air_yards, DEPTH_BINS, labels=DEPTH_LABS)

# FTN charting (2022+) says which read each pass went to; participation gives time to throw.
ftn = nfl().load_ftn_charting([2022, 2023, 2024, 2025]).to_pandas()
ftn["read_thrown"] = ftn.read_thrown.astype(str).str.strip()
part = nfl().load_participation([2022, 2023, 2024, 2025]).to_pandas()[["nflverse_game_id", "play_id", "time_to_throw"]]
p22 = pbp([2022, 2023, 2024, 2025], columns=PCOLS)
p22 = p22[(p22.season_type == "REG") & (p22.play_type == "pass") & (p22.pass_attempt == 1) & (p22.sack == 0)]
reads = (p22.merge(ftn[["nflverse_game_id", "nflverse_play_id", "read_thrown", "is_throw_away", "is_screen_pass",
                        "is_qb_out_of_pocket"]],
                   left_on=["game_id", "play_id"], right_on=["nflverse_game_id", "nflverse_play_id"], how="inner")
         .merge(part, left_on=["game_id", "play_id"], right_on=["nflverse_game_id", "play_id"], how="left",
                suffixes=("", "_p")))

# Numbers quoted in the text
_r25 = reads[reads.season == 2025]
TTT25 = _r25.time_to_throw.median()                                    # median snap-to-throw, 2025
_p25 = passes[passes.season == 2025]
LG_ADOT_25 = _p25.air_yards.mean()
_c25 = _p25[_p25.complete_pass == 1]
YAC_SHARE = _c25.yards_after_catch.sum() / _c25.receiving_yards.sum()
# Throws behind the line: what a completed one gains, and why the group still loses value
_beh = passes[passes.depth == "Behind\nLOS"]
BEH_YDS = _beh.loc[_beh.complete_pass == 1, "yards_gained"].mean()
INT_DEEP = passes.loc[passes.air_yards >= 20, "interception"].mean()
INT_SHORT = passes.loc[passes.air_yards < 20, "interception"].mean()
# EPA of a 4- and a 5-yard gain on first-and-10 (runs and passes, 2021-2025)
_r = pbp(SEASONS, columns=["season_type", "down", "ydstogo", "yards_gained", "play_type", "epa"])
_f = _r[(_r.season_type == "REG") & (_r.down == 1) & (_r.ydstogo == 10) & _r.play_type.isin(["pass", "run"])]
EPA_1ST = {y: _f.loc[_f.yards_gained == y, "epa"].mean() for y in (4, 5, 6)}

_db = pbp([2025], columns=["season_type", "qb_dropback", "shotgun", "play_type"])
_db = _db[(_db.season_type == "REG") & (_db.qb_dropback == 1) & _db.play_type.isin(["pass", "run"])]
GUN_DB_25 = _db.shotgun.mean()                                         # dropbacks from shotgun/pistol

Second-and-8 near midfield. The quarterback takes the snap under center, takes five quick steps backward, plants, hops forward once, and throws toward the sideline, to a patch of grass where nobody is standing. The receiver he is throwing to is still sprinting straight up the field, with the cornerback backpedalling in front of him. A beat later the receiver snaps his head around, cuts hard toward the sideline, and the ball arrives at that patch of grass at the same moment he does, a step ahead of the cornerback. The analyst says “that’s timing.” How did the quarterback know where to throw before the receiver got there? Why five steps and not three or seven? And when the cornerback does get there first, how does he decide to throw somewhere else? This chapter is about the three things that make every pass play work: the routes (where the receivers go), the drop (when the ball comes out), and the read (who gets it).

You’ll be able to…

Reading the diagrams: the conventions of What a Play Really Is (blue circles on offense, orange squares on defense, a green ring on the player to watch), plus a few that are new here. X is the wide receiver on the line, Z the one off it, Y usually the tight end, H the third receiver, and $ the nickel back (the slot cornerback). Blue arrows are routes. Orange arrows are defenders: dashed when he drops into a zone, solid when he runs with a receiver or drives on the ball. A dotted line with an arrowhead is a choice, a path he could take; the thin dotted line behind the quarterback is his footsteps. Grey routes are on the play but not the point of the picture. The small numbers on the left edge give the depth past the line of scrimmage.

The passing problem: a race against the rush

A pass play has a clock on it, and the defense controls it: four or more rushers start running at the quarterback at the snap, and his linemen can’t hold them off forever. In the 2025 regular season the median pass left the quarterback’s hand 2.5 seconds after the snap.1 Be careful what that number means. It is not how long the line can hold; plenty of deep throws leave after three seconds, so a good pocket often lasts longer, and Pass Protection measures it. It is how long quarterbacks choose to hold the ball, and they choose short because every extra half-second is a gamble that the rush hasn’t arrived. Two and a half seconds is the working budget: the time for receivers to get somewhere useful, for the quarterback to decide, and for the ball to leave.

Inside that budget the offense solves three problems at once. Where: each receiver needs a path to open grass, or to a spot where his defender is at a disadvantage; that is his route. When: the quarterback must be ready to throw the moment the receiver gets there, not before (the defender is still close) and not after (the defender has recovered, or the rush has arrived); his footwork, the drop, synchronises the two. Who: up to five players can go out, and the quarterback needs a plan for choosing one in a second or two; that plan is the read. Every pass concept in the rest of Part 4 is a particular answer to those three questions.

NoteMadden vs. real life

In the video game a pass play is five fixed lines, and you choose a receiver by pressing his button whenever you like. In a real offense a route is a set of instructions that can change with the coverage and the down and distance, and the quarterback can’t wait until a receiver looks open. By the time a receiver looks open on a television screen, it is usually too late to throw it to him. Real quarterbacks throw to where a receiver is about to be open, on a count their feet keep.

Anatomy of a route

A route has four parts.

  • The release. His first steps off the line, getting past the man in front of him. Against a press cornerback standing nose to nose with him, who tries to jam him (hit him with both hands and knock him off his path), the release is a fight of its own; The Quick Game covers the release techniques.
  • The stem. The run upfield before the cut. A good stem looks the same for every route, so the defender can’t tell from it which way the receiver will break.
  • The break. The cut. Its depth is set by rule (“break at 12 yards”, measured from the line of scrimmage, not from where he lined up), and its direction names the route. Breaks come in two kinds. A speed break is rounded, with no plant, to keep the receiver’s speed; it suits quick routes against defenders guarding areas. A sink-and-plant break drops the hips and stops hard, to shake a defender running with him; deep outs and comebacks use it, and many coaches have the 12-yard out break slightly back toward the quarterback rather than dead flat.
  • The finish. What happens after the cut: keep running away from the defender, stop and face the quarterback, or settle in a gap between defenders.

The break is the moment that matters. A receiver can’t outrun a defender who is backpedalling in front of him, but he can make that defender guess. At the break the receiver knows where he is going and the defender doesn’t, so for a fraction of a second the receiver is ahead. That gap, a yard or two of separation, closes fast. Everything about timing is about getting the ball there before it does.

The route tree: a shared language

Receivers and quarterbacks need to talk about routes quickly, in a huddle or with a hand signal at the line. Most offenses therefore teach a route tree: a numbered menu of the basic routes, drawn as branches off one straight stem. Here is one common version, for a receiver on the right side of the formation.

Show the Python
W0 = 13.0                                    # the receiver: on the line, just inside the numbers
TREE = [  # number, name, absolute (d, w) waypoints after the start, label spot, label alignment
    (0, "hitch", [(6.3, W0), (4.7, W0 - 1.4)], (5.6, 15.6), "left"),
    (1, "flat", [(1.4, W0 + 1.5), (2.4, W0 + 6.5)], (2.4, 20.4), "left"),
    (2, "slant", [(1.8, W0), (6.6, W0 - 6.2)], (6.6, 6.1), "right"),
    (3, "comeback", [(16.0, W0), (13.6, W0 + 3.0)], (13.6, 16.8), "left"),
    (4, "curl", [(12.0, W0), (9.9, W0 - 2.3)], (9.9, 10.0), "right"),
    (5, "out", [(10.0, W0), (10.0, W0 + 6.5)], (10.0, 20.4), "left"),
    (6, "dig (in)", [(14.0, W0), (14.0, 2.5)], (15.6, 2.4), "left"),
    (7, "corner", [(12.0, W0), (22.0, W0 + 7.0)], (23.8, 19.8), "center"),
    (8, "post", [(12.0, W0), (25.5, W0 - 7.5)], (27.0, 5.5), "center"),
    (9, "go", [(29.5, W0)], (31.0, W0), "center"),
]
off = [Player("C", "C", "off", -0.7, 0.0, label="C"), Player("QB", "QB", "off", -5.0, 0.0, label="Q")]
off += [Player(f"R{n}", "WR", "off", -0.7, W0, label="") for n, *_ in TREE]
off[-1] = off[-1].moved(label="X")          # one labelled marker on top of the stack (X: on the line)
tree = Play(off, [], los=35, to_go=None)
for n, name, pts, *_ in TREE:
    tree.route(f"R{n}", pts, absolute=True)
fig = plt.figure(figsize=(6.5, 4.35))
gs = fig.add_gridspec(1, 2, width_ratios=[1.45, 1.0], wspace=0.04)
ax, axl = fig.add_subplot(gs[0]), fig.add_subplot(gs[1])
fld = tree.draw(ax=ax, lateral=(-6, 26.6), window=(-6.5, 32.5), title=False)
no_numbers(fld.ax)
ruler(fld, tree, top=30)
for n, name, pts, at, ha in TREE:
    tag(fld, tree, f"{n}  {name}", at, ha=ha, ec=ODD if n % 2 else EVEN, bold=True, fs=7.2)
tag(fld, tree, "toward the middle", (-4.6, 7.0), fs=6.6)
tag(fld, tree, "toward the sideline", (-4.6, 19.0), fs=6.6)
# the cheat sheet beside the tree
CHEAT = [(0, "hitch", "5-6", "stop, face QB"), (1, "flat", "0-3", "out"), (2, "slant", "3-6", "in, 45°"),
         (3, "comeback", "15, back to 12", "back, out"), (4, "curl", "10-14", "stop, face QB"), (5, "out", "10-14", "out, ~square"),
         (6, "dig", "10-15", "in, square"), (7, "corner", "10-12", "out, climbs"), (8, "post", "10-14", "in, climbs"),
         (9, "go", "—", "straight")]
axl.axis("off")
axl.set_xlim(0, 1)
axl.set_ylim(0, 1)
axl.text(0.025, 0.96, "#", fontsize=7.2, fontweight="bold", color=style.INK, va="center", family="DejaVu Sans Mono")
axl.text(0.11, 0.96, f"{'Route':<9} {'Break (yd)':<15} Direction", fontsize=7.2, fontweight="bold", color=style.INK,
         va="center", family="DejaVu Sans Mono")
for i, (n, name, dep, dirn) in enumerate(CHEAT):
    y = 0.885 - i * 0.07
    axl.text(0.02, y, f"{n}", fontsize=7.6, fontweight="bold", va="center", color=style.INK,
             bbox=dict(boxstyle="round,pad=0.2", fc=style.SURFACE, ec=ODD if n % 2 else EVEN, lw=1.1))
    axl.text(0.11, y, f"{name:<9} {dep:<15} {dirn}", fontsize=7.2, va="center", color=style.INK,
             family="DejaVu Sans Mono")
axl.text(0.02, 0.0, "Box colour: dark blue = odd, breaks toward the\nsideline; green = even, breaks toward the middle.\nDepths in yards past the line; typical, not laws.",
         fontsize=6.8, color=style.INK_2, va="bottom")
plt.show()
Figure 20.1: One common numbered route tree, drawn for a wide receiver on the right of the formation (the ball is at the bottom left, the right sideline at the right edge). Every route starts with the same push straight up the field and splits at a break point. Odd numbers (dark blue tags) break toward the sideline, even numbers (green tags) toward the middle, and the numbers roughly grow with depth. The 0, the hitch, is an addition some systems make. The table beside the tree lists each route’s typical break depth, in yards past the line of scrimmage, and its direction.

Two patterns make the tree easy to remember. Odd numbers break toward the sideline and even numbers break toward the middle, and the numbers roughly grow with depth. A receiver on the left simply mirrors the tree, so “breaks outside” always means “toward his own sideline.” Each branch exists for a reason.

  • 1, the flat route. A quick release to the flat, the strip near the sideline within a few yards of the line. Its job is often to pull a defender down and out with it, opening the space above him.
  • 2, the slant. One to three steps upfield, then a sharp cut inside at about 45 degrees, between the defenders near the line and the ones behind them, with the receiver’s body shielding the ball from a defender outside him. It is one of the quickest routes in football, which makes it a favourite hot route: when the defense sends more rushers than the line can block, the quarterback throws at once to a receiver running into the space a rusher just left (Pass Protection).
  • 3, the comeback. A hard push for about 15 yards to sell a deep route, then a stop and a turn back toward the sideline. The more convincing the push, the further a cornerback afraid of the deep ball retreats, and the more room there is underneath.
  • 0 and 4, the hitch. Push upfield, stop, and turn back to face the quarterback. At 5–6 yards it is a hitch (some trees number it 0, many not at all); at 10–14 yards, turning back slightly toward the middle, it is a curl, the 4. Both attack a defender who is backing up: against a cornerback 7 or 8 yards off, a 5-yard hitch is open almost by definition.
  • 5, the out. Straight up the field, then a cut toward the sideline: a quick out at about 5 yards, the deeper out at 10–14. It attacks the area outside the defenders who guard the middle, and the sideline stops the clock.
  • 6, the dig. Up the field 10–15 yards, then a flat cut toward the middle, behind the linebackers; shallower versions are often called an in route.
  • 7, the corner route. Up 10–12 yards, then an angle toward the sideline and the pylon at the corner of the end zone (hence its other name, the flag), above a cornerback who stays low and outside a safety who stays in the middle.
  • 8, the post. Up the field, then an angle deep toward the goalposts in the middle, between deep defenders or behind a safety who has come forward. One that angles only slightly inside is a skinny post.
  • 9, the go route. A straight sprint, also called a fly or a 9 route, to outrun the man covering him. Even unthrown it does a job: a cornerback running with it is no longer near the short routes. A go that drifts toward the sideline is a fade, and a ball thrown short to his sideline shoulder, so he turns back for it while the defender keeps running, is a back-shoulder throw (Red Zone, Goal Line, Two-Point Plays, and Short Yardage).

Where the ball sits changes the menu. The ball is spotted on or between the hash marks, so one side of the formation is usually wider (the field side) and the other shorter (the boundary side). Outs and comebacks usually go to the boundary, where the throw is shorter. A deep out from the far hash to the wide side travels some 30 yards in the air before the cornerback can come down on it, the classic test of a quarterback’s arm. Receivers also keep a sideline rule, finishing their routes about 5–6 yards inside the sideline so an out, a comeback or a fade has room to land, which is one reason they move their alignment from play to play, out to the numbers or in to a reduced split (Formation Rules and the Language of Alignment).

Read the tree in pairs. A receiver runs one route per play, but his defender has to defend all of them, and many routes are built to look like another for as long as possible. The slant and the sluggo (a slant fake, then up the field) start the same way; so do the hitch and the hitch-and-go, and the post and the post-corner. A cornerback who jumps the first route all afternoon gets the second. Pairs work across a game, as calls; inside one route, as a double move; and across two receivers, as the stretches later in this chapter. The idea is the same: make a defender choose, then throw where he didn’t go.

ImportantWhy it exists

The tree makes calls short and practice efficient. If every route has a number and a fixed depth, one digit tells a receiver where to go, and a quarterback and receiver who have run the 5 a thousand times know, without talking, where the ball will be and when. The cost is rigidity: a number says nothing about how two routes work together, and a fixed depth is easy to study. So modern playbooks name combinations of routes (concepts), and teach many routes with rules that change them against different coverages (Offensive Systems and Play-Calling Languages).

Who numbered the routes, and what a call sounds like

The numbered tree is usually traced to Don Coryell, whose “Air Coryell” San Diego Chargers (1978–1986) built on Sid Gillman’s principle of making a defense cover every yard of the field, and led the NFL in passing yards six straight seasons from 1978 to 1983 and again in 1985. Numbers let a play-caller give three receivers their routes in three digits. In the classic version the digits go to the split end, the tight end and the flanker, in that order, so “896” sends X on an 8, Y on a 9 and Z on a 6: with this chapter’s tree, a post, a go up the seam and a dig.2 It never became a standard; some trees reverse the odd-even rule or swap the 3 and the 5. “A 9 route” almost always means a go; most other numbers depend on who is talking. Offensive Systems and Play-Calling Languages builds the digit system into full calls.

The names travel better, with dialects: a go is also a fly, a streak or a 9; a dig a square-in; a hitch a stop; a corner a flag; a skinny post a glance or a bang-8; an option route a choice; a high-low a hi-lo or “levels”. Some systems split the hitch family into a hook, which turns straight back, and a curl, which rounds inside.

CautionCommon misconception

“The receiver runs the route as drawn.” Routes are drawn as lines but taught as rules, and the depths in the tree are targets, not laws. A 12-yard out on third-and-14 is run at 14 yards, past the line to gain. A receiver who sees a defender sitting on his spot may be taught to adjust, and on some routes the adjustment is the whole point (the option routes below). The quarterback has to know the rule as well as the receiver does.

Off the tree: seam, wheel, whip and sluggo

The tree describes what one wide receiver can do from the outside, with the sideline on one side of him and the whole field on the other. A receiver inside, in the slot or at tight end, lives in a different place: he has room to break either way, he is usually covered by a slower linebacker or a nickel back rather than a cornerback, and he is closest to the deep middle. So inside receivers, and backs who start in the backfield, have routes of their own, and so do receivers trying to fool a defender rather than simply get past him. Four come up constantly.

Show the Python
off = offense("gun_2x2")
off = [p.moved(w=-17.0) if p.key == "X" else p.moved(w=-8.0) if p.key == "H"
       else p.moved(w=8.0, label="Y") if p.key == "Y" else p.moved(w=15.0) if p.key == "Z"
       else p.moved(w=2.6) if p.key == "RB" else p for p in off]
ot = Play(off, [], los=35, to_go=None)
ot.route("X", r.path((2.0, 0.0), (4.3, -2.2), (25.0, -2.0)))                 # sluggo: slant fake, then go
ot.route("H", r.path((3.0, -0.6), (4.6, -3.0), (4.2, 3.4)))                  # whip: in, plant, back out
ot.route("Y", r.seam(24))                                                      # seam
ot.route("RB", r.path((0.3, 5.0), (1.8, 11.0), (3.5, 15.0), (6.0, 17.0), (24.0, 17.4)), side=+1)  # wheel
ot.dropback(2.0)
fld = ot.draw(lateral=(-22, 26.6), window=(-7.5, 28.5), title=False)
faded_route(fld, ot, [(-0.7, 15.0), (12.0, 15.0), (24.0, 10.8)])          # Z clears inside for the wheel
no_numbers(fld.ax)
ruler(fld, ot, top=25)
tag(fld, ot, "sluggo (slant-and-go)", (26.8, -15.0), bold=True)
tag(fld, ot, "whip", (1.6, -12.6), bold=True)
tag(fld, ot, "seam", (24.4, 7.6), bold=True)
tag(fld, ot, "wheel", (21.0, 22.6), bold=True)
tag(fld, ot, "Z clears", (26.3, 10.8), fs=6.2, color=style.INK_2)
tag(fld, ot, "hash", (-6.4, -3.1), fs=6.0, color=style.INK_2)
tag(fld, ot, "hash", (-6.4, 3.1), fs=6.0, color=style.INK_2)
plt.show()
Figure 20.2: Four routes that live off the tree, from a 2x2 formation (the other routes are left off). Left: the sluggo (X fakes a slant for two steps, then runs straight up the field) and the whip (H runs at the middle as if on a short in-cut, plants, and snaps back out). Right: the seam (Y runs straight up between the hash and the numbers) and the wheel (the back runs to the flat, then turns up the sideline, outside the numbers). The wheel needs room, so Z clears it out by running inside (grey).
  • The seam. A go route from the inside, straight up the strip between the hash marks and the painted numbers. In most deep coverages that strip is the seam between the safety in the middle and the deep defender outside, so the route forces the defense to decide who takes it: the slot’s and tight end’s best way to threaten the deep middle.
  • The wheel. Out toward the flat, then up the sideline, usually by a back or a slot receiver. A defender who sees a back heading for the flat comes forward, and the back turns up the field past him; few linebackers covering a back man-to-man can run with him. It needs the sideline cleared, so the outside receiver on that side runs inside.
  • The whip. A slot receiver runs at the middle as if on a shallow in-cut, plants, and snaps back toward the sideline: the harder a man-to-man defender drives on the in-cut, the more open the receiver is when he reverses.
  • The sluggo. “Slant and go”: a two-step slant fake, then straight up the field, aimed at a defender who has been jumping slants all game. It is the best-known double move (a route that shows one break and runs another). Double moves take time, so they need protection and usually a pump fake (the quarterback starts a throwing motion and holds the ball); The Quick Game covers the family.

Option routes: the receiver gets a vote

The tree assumes the receiver knows his route before the snap. On an option route (also called a choice route) he doesn’t: he picks the finish after the snap, from what the defense does. The rule is usually something like this: “Push to six yards. If one man is following you, break away from him, out if he’s inside, in if he’s outside. If the defenders are guarding areas, find the hole between them and sit down, facing the quarterback.”

Show the Python
def option_play(man):
    off = [p for p in offense("gun_2x2") if p.key in ("C", "RG", "RT", "QB", "Y", "Z")]
    off = [p.moved(w=8.5, label="Y") if p.key == "Y" else p.moved(w=16.0) if p.key == "Z" else p for p in off]
    dfn = [Player("NB", "NB", "def", 1.6 if man else 3.8, 7.6 if man else 11.2, label="$"),
           Player("MIKE", "MIKE", "def", 4.5, 1.5, label="M"),
           Player("RCB", "CB", "def", 7.0, 16.5, label="CB")]
    p = Play(off, dfn, los=35, to_go=None)
    p.dropback(2.5)
    if man:
        p.route("Y", r.path((7.5, 0.0), (8.0, 5.0)))                       # breaks out at 6 yards
        p.path("NB", [(5.6, 8.0), (6.4, 11.0)], absolute=True, speed=6.0, delay=0.25)  # trails, inside hip
        p.path("RCB", [(13.5, 17.0)], absolute=True, speed=5.5)      # carries Z deep
        p.route("Z", r.go(14.5))
    else:
        p.route("Y", r.path((7.5, 0.0), (7.9, -0.8), (7.0, -1.4)))              # sits and turns back
        p.drop("NB", (7.0, 14.2))
        p.drop("MIKE", (8.0, 3.0))
        p.drop("RCB", (13.5, 15.5))
        p.route("Z", r.go(14.5))
    return p


fig, axes = plt.subplots(1, 2, figsize=(6.5, 3.6))
for ax, man, ttl in ((axes[0], True, "Against man: break away from him"),
                     (axes[1], False, "Against zone: sit in the hole")):
    p = option_play(man)
    fld = p.draw(ax=ax, lateral=(-3, 21), window=(-7, 16.5), title=False)
    no_numbers(ax)
    y0 = p.players["Y"]
    if man:
        dotted(fld, p, [(5.4, y0.w), (6.8, y0.w - 4.6)], color=style.MUTED)
        tag(fld, p, "$ sits inside:\nY breaks out", (11.6, 4.6), fs=6.4)
    else:
        tag(fld, p, "Y sits in the hole\nbetween M and $,\nturns to face Q", (11.6, 6.0), fs=6.4)
    ax.set_title(ttl, loc="left", fontsize=8.6, fontweight="bold", color=style.INK)
fig.tight_layout(w_pad=0.6)
plt.show()
Figure 20.3: One option route, two defenses. The slot receiver (Y) pushes to about 6 yards, then decides. Left, man coverage: the nickel defender ($) lines up on Y’s inside shoulder to take away the middle and runs with him (solid orange), so Y breaks out, away from him, and the defender is left trailing on his inside hip (the dotted line is the in-cut Y would run if the defender were outside). Right, zone: the defenders drop to areas (dashed) and watch the quarterback, so Y settles in the hole between the Mike (M), who guards the short middle, and the nickel ($), who guards the short area outside, and turns back to face the quarterback.

Why give the receiver a choice? Because the right route depends on the coverage, which isn’t settled until after the snap. Against man coverage, a defender on the receiver’s inside shoulder is taking away the middle, so the open break is outside, and vice versa. That position is the defender’s leverage, and the receiver, with the two-way go of Spread Formations, breaks away from it. Against zone coverage, the underneath defenders watch the quarterback and nobody follows the receiver, so running away from someone is pointless; he stops in the space between two of them. One call, right against both. Two refinements are common. Against a man defender lined up straight in front of him, the receiver stems right at him to make him commit to one side, then breaks to the other. And many systems tie the choice to the safeties: with one deep safety in the middle (MOFC, “middle of the field closed”) he breaks away from his defender, and with the middle empty (MOFO, “open”) he sits; Coverage Fundamentals explains why.

The cost is that two people must make the same decision in a split second. If the receiver sees man and breaks out while the quarterback sees zone and throws to where he should have sat down, the ball goes to nobody, or to a defender, so option routes go to the receivers a quarterback trusts most. The Patriots of the Tom Brady era were famous for building their passing game around option routes for slot receivers such as Wes Welker and Julian Edelman.3 Many other routes carry smaller rules of the same kind (“against two deep safeties, bend the seam into the middle”), which Dropback Concepts collects as route conversions.

The drop: the quarterback’s feet are a clock

Now the “when.” On a dropback (any called pass play on which the quarterback sets up behind his line to throw, including play-action and the ones that end in a sack or a scramble), his feet follow a prescribed pattern called a drop, counted in steps, and each drop is paired with a depth of route.

From under center, where he takes the snap with his hands under the center and starts about a yard and a half behind the line, the classic menu is:

  • Three-step drop: about 5 yards deep, finished a little under a second after the snap. It goes with the quick routes that break at 5–6 yards: hitches, slants, quick outs. The ball should leave as his third step lands, with no extra step, more or less as the receiver breaks.
  • Five-step drop: about 7 yards deep, finished in roughly a second and a half. It goes with the intermediate routes at 10–14 yards: outs, curls, digs. The quarterback usually adds a short step forward at the end of the drop, a hitch step (not to be confused with the hitch route), to gather himself, and throws.
  • Seven-step drop: about 9 yards deep, finished in about two seconds. It goes with the deep routes at 15–20 yards and beyond: comebacks, posts, corners, deep digs. It is the slowest drop and the one that most needs the line to hold, which is a big reason it is rarer now than in the 1970s and 80s.

From the shotgun, the quarterback starts about five yards deep, so he needs fewer steps to reach the same depths. The pairs become one, three and five steps: a one-step drop (catch the snap, one step back, throw) for the quick game, three steps for intermediate routes and five for deep ones. Catching the snap is not a step; count from his first step back. The pairing of depth and time stays the same.4 On the standard broadcast angle, high behind the quarterback, watch his feet rather than the ball: count the steps back, look for the plant of the last one, and then for the small hop forward, the hitch step.

Show the Python
_tt = reads[(reads.season >= 2023) & reads.air_yards.notna()].dropna(subset=["time_to_throw"])
TTT_MED = {"quick": _tt.loc[_tt.air_yards.between(0, 9), "time_to_throw"].median(),
           "mid": _tt.loc[_tt.air_yards.between(10, 14), "time_to_throw"].median(),
           "deep": _tt.loc[_tt.air_yards >= 15, "time_to_throw"].median()}

fig = plt.figure(figsize=(6.5, 3.3))
gs = fig.add_gridspec(1, 2, width_ratios=[1.0, 2.6], wspace=0.08)
axd, axt = fig.add_subplot(gs[0]), fig.add_subplot(gs[1])

# --- left: drop depths
axd.axhline(0, color=style.OFFENSE_DARK, lw=1.4, alpha=0.75)
axd.text(1.0, 0.35, "line of scrimmage", ha="center", va="bottom", fontsize=6.6, color=style.INK_2)
DEPTHS = {0: [("start", 1.5), ("3-step", 5.0), ("5-step", 7.0), ("7-step", 9.0)],
          2: [("start", 5.0), ("1-step", 6.0), ("3-step", 7.5), ("5-step", 9.0)]}
for x, lst in DEPTHS.items():
    axd.plot([x, x], [-lst[0][1], -lst[-1][1]], color=style.BASELINE, lw=3, solid_capstyle="round", zorder=1)
    for name, d in lst:
        start = name == "start"
        axd.scatter([x], [-d], s=55 if start else 34, color=style.OFFENSE if start else style.SURFACE,
                    edgecolor=style.OFFENSE, lw=1.4, zorder=3)
        axd.text(x + 0.22, -d, name if not start else "QB starts", fontsize=6.6, va="center", ha="left",
                 color=style.INK if not start else style.INK_2)
axd.set_xticks([0, 2], ["Under\ncenter", "Shotgun"], fontsize=7.2)
axd.set_yticks([0, -5, -7, -9], ["LOS", "5 yd", "7 yd", "9 yd"], fontsize=7)
axd.set_xlim(-0.5, 3.6)
axd.set_ylim(-10.2, 1.4)
axd.grid(False)
axd.spines["bottom"].set_visible(False)
axd.tick_params(axis="x", length=0)
axd.set_title("Where the drop ends", fontsize=8.6)

# --- right: timing
ROWS = [  # key, label, drop end, set/hitch end, throw, break time, route text
    ("quick", "Quick: 3-step\n(gun: 1-step)", 0.95, 0.95, 0.95, 1.0, "routes at 5-6 yd: hitch, slant, quick out"),
    ("mid", "Intermediate: 5-step\n(gun: 3-step)", 1.45, 1.85, 1.85, 1.95, "routes at 10-14 yd: out, curl, dig"),
    ("deep", "Deep: 7-step\n(gun: 5-step)", 1.95, 2.45, 2.45, 2.6, "routes at 15-20 yd: comeback, post, corner"),
]
for i, (key, lab, drop, hitch, throw, brk, txt) in enumerate(ROWS):
    y = -i * 1.25
    axt.barh(y + 0.2, drop, height=0.26, color=style.OFFENSE, alpha=0.9)
    if hitch > drop:
        axt.barh(y + 0.2, hitch - drop, left=drop, height=0.26, color=style.OFFENSE_TINT, edgecolor=style.OFFENSE, lw=0.6)
    axt.barh(y - 0.17, brk, height=0.2, color=style.THIRD, alpha=0.75)
    axt.scatter([brk], [y - 0.17], marker="D", s=30, color=style.THIRD, edgecolor="white", zorder=4)
    axt.scatter([throw], [y + 0.2], marker="o", s=60, color=style.BALL, edgecolor="white", zorder=5)
    axt.plot([TTT_MED[key]] * 2, [y - 0.38, y + 0.42], color=style.INK, lw=1.6, zorder=6)
    axt.text(0.03, y - 0.48, txt, fontsize=6.6, color=style.INK_2, va="center")
axt.set_yticks([-i * 1.25 for i in range(3)], [r[1] for r in ROWS], fontsize=7.2)
axt.set_xlim(0, 3.4)
axt.set_ylim(-3.15, 0.65)
axt.set_xlabel("Seconds after the snap", fontsize=7.5)
axt.tick_params(axis="x", labelsize=7)
axt.grid(axis="y", visible=False)
axt.yaxis.tick_right()
axt.spines["left"].set_visible(False)
axt.spines["right"].set_visible(True)
axt.set_title("When the ball should come out", fontsize=8.6)
h = [Patch(color=style.OFFENSE, label="QB drop"), Patch(fc=style.OFFENSE_TINT, ec=style.OFFENSE, label="hitch step"),
     Patch(color=style.THIRD, alpha=0.75, label="receiver to break"),
     Line2D([], [], marker="o", ls="", color=style.BALL, label="ball out"),
     Line2D([], [], color=style.INK, lw=1.6, label="real median, 2023-25")]
fig.legend(handles=h, loc="upper center", bbox_to_anchor=(0.5, 0.02), ncol=5, fontsize=6.8, frameon=False,
           handlelength=1.4, columnspacing=1.0)
plt.show()
Figure 20.4: Drops and routes are built as pairs. Left: roughly where the quarterback’s last step lands for each drop, measured from the line of scrimmage. Shotgun drops are two steps shorter for the same depth because he starts five yards back. Right: the textbook timing of each pair, in seconds after the snap: the quarterback’s drop (blue), his hitch step if the drop has one (light blue), the receiver’s run to his break (green, diamond = the break), and the moment the ball should leave (brown ball). On the quick game there is no hitch step: the ball leaves as the last step lands. The black tick on each row is the real median time to throw for passes of that depth, 2023-2025, which runs later than the textbook because many throws are not first-read, on-time throws (the chart on progressions shows why). Textbook depths and times are typical values; they vary by team and quarterback.

Why pair them? Because both are clocks, and the offense wants them to ring together. A receiver starting from a standstill, with a defender in his way, reaches 5 yards in about a second and 12 in about two; a quarterback backing up covers his drop on about the same schedule. Matched, the quarterback’s foot lands as the receiver cuts, and the ball leaves without a pause. Mismatched, one of them waits: a seven-step drop for a hitch hands the cornerback two free seconds to close, and a three-step drop for a post leaves the quarterback holding the ball while the rush arrives.

The black ticks are a reality check: in 2023–2025 the median throw 10–14 yards downfield left about 2.7 seconds after the snap, and the median deep throw about 3.0. Real throws run later because the medians include second and third reads, scrambles and backs released late. The textbook is the plan; the progression data below show what happens when the plan doesn’t work.

ImportantWhy it exists

Drops turn a judgment (“is he open yet?”) into a rhythm (“throw on the fifth step”). A quarterback who waits to see a receiver open is late, because a receiver who looks open has usually been open for half a second already, and the ball needs another second to get there. A quarterback whose feet tell him when the receiver will be at his spot can throw before the defender has reacted to the break. The cost is that a rhythm can be disrupted: a pass rusher in his face, a receiver jammed off his path at the line, or a defender sitting on the spot breaks the timing. Then the footwork carries him to his next option: the hitch step that ends the drop is also the moment he moves his eyes to the second receiver (more under Reads, below).

Three complications. The shotgun dominates: in 2025 about 82% of NFL dropbacks started from the shotgun or the pistol,5 where the quarterback sees the defense better and reaches his depth sooner. Play-action changes the footwork: a handoff fake comes first, so the routes are timed to a longer clock (Play-Action, Bootlegs, and Screens). Not every throw comes off a drop: quarterbacks roll out and escape a collapsing pocket, and the routes then follow the scramble rules of Quarterback Play.

Timing routes: throw it before he breaks

Put the pieces together and you get a timing route: a route the quarterback throws to a spot at a set moment, as his drop ends and often before the receiver has made his break. This is the play from the opening paragraph.

Show the Python
def timing_out():
    off = [p for p in offense("singleback") if p.key not in ("H",)]
    off = [p.moved(w=14.0) if p.key == "Z" else p for p in off]       # on the numbers: room for the out
    dfn = defense("nickel", "single_high", off)
    keep = {"SDE", "SDT", "WDT", "WDE", "MIKE", "WILL", "FS", "SS", "RCB", "LCB"}
    dfn = [p for p in dfn if p.key in keep]
    mv = {"RCB": (7.5, 14.5), "SS": (6.5, 8.0), "FS": (13.0, 0.0)}
    dfn = [p.moved(d=mv[p.key][0], w=mv[p.key][1], label="CB" if p.key == "RCB" else None) if p.key in mv
           else p.moved(label="CB") if p.key == "LCB" else p for p in dfn]
    p = Play(off, dfn, los=40, to_go=10)
    protect(p)
    p.block("Y", pts=[(-0.4, 0.3)])
    p.block("RB", pts=[(1.5, 2.2)])
    p.dropback(5.5, speed=3.8)                         # five steps from under center, done at ~1.45 s
    p.path("QB", [(0.8, 0.0)], speed=2.0)              # the hitch step: ball out at ~1.85 s
    p.route("Z", r.path((13.5, 0.0), (13.5, 8.0)), speed=6.9)   # breaks at 12 yards, ~1.95 s
    p.route("X", r.go(20))
    p.path("RCB", [(15.5, 15.0)], absolute=True, speed=5.0)            # backs up into the deep third
    p.path("RCB", [(14.2, 19.0)], absolute=True, speed=6.5, delay=0.45)  # drives once Z has broken
    p.drop("SS", (9.0, 11.0))                          # the short-outside defender gets depth
    p.drop("FS", (16.0, 1.0))
    p.drop("MIKE", (8.0, 2.0))
    p.highlight("Z", "RCB")
    p.pass_to("Z", t=1.85)
    return p


def catch_time(play, to, t0, t1=5.0):
    """First moment the ball reaches the receiver (the simulation has no explicit catch event)."""
    for t in np.arange(t0, t1, 0.01):
        if np.linalg.norm(play.ball_position(t) - play.position(to, t)) < 0.05:
            return float(t)
    return t1


tp = timing_out()
with ball_speed(22):
    T_CATCH = round(catch_time(tp, "Z", 1.9), 2)
    _a, _b = tp.pass_target_spot()
    THROW_LEN = float(np.linalg.norm(_b - _a))
TIMES = [0.9, 1.85, 2.45, T_CATCH]
if PDF:
    with ball_speed(22):
        strip(tp, TIMES,
              ["+0.9 s: Z still looks like a go route", "+1.85 s: hitch step, ball out",
               "+2.45 s: Z has broken; the corner drives", f"+{T_CATCH:.1f} s: catch; the corner is a step late"],
              lateral=(-3, 26.6), window=(-9.5, 18.5), rings=("Z", "RCB"), ncols=2, rulers=False, width_in=5.6)
        plt.show()
else:
    with ball_speed(22):
        show_animation(tp, times=TIMES)
Figure 20.5: A timing route: the 12-yard out from a five-step drop under center. The cornerback lines up 7 to 8 yards off Z and, at the snap, backs up to guard the deepest third of the field on his side (a deep third). The ball leaves the quarterback’s hand on his hitch step, a tenth of a second before Z breaks toward the sideline, and is thrown to a spot, not to the man. The cornerback can only drive on the ball once Z breaks, so he arrives a step after it. Panels are seconds after the snap (simulated play; Z and the cornerback are ringed; the ball is drawn beside the quarterback until he throws it).

Follow the cornerback. At 0.9 seconds he is backing up, watching Z run straight at him: this could still be a go route. At 1.85 seconds the quarterback’s hitch step lands and he throws, to a spot near the sideline 12 yards downfield. Z still hasn’t cut. Only when Z breaks, a tenth of a second later, can the cornerback drive toward the sideline, and by then the ball is on its way. Z catches it at about 3.2 seconds with the cornerback a step behind.

Why doesn’t he break when the ball leaves the quarterback’s hand? Because he isn’t watching the quarterback. A defender guarding a deep zone has one job above all, not to be beaten deep, so his eyes are on the receiver who can beat him, and until Z breaks, an out and a go look the same. (The underneath zone defenders, with no deep job, are the ones who watch the quarterback.) A cornerback who guesses early and jumps the out gets beaten by the go: the route tree’s pair logic again. Against this coverage the out usually comes with a flat route from the next receiver inside, to hold the short-outside defender down; here Y stays in to block, so that defender is free to widen, one more reason the ball must be on time.

The throw is to a spot, and it leaves before the receiver gets there, because it has a long way to go: some 29 yards from the quarterback’s hand to the catch, more than a second in the air even at the firm 22 yards a second this simulation uses. That makes it hard to defend and hard to execute: a receiver who is jammed and arrives late, or rounds his cut and arrives a yard short, turns a completion into an incompletion, or an interception. Coaches have related words: a rhythm throw comes out on the last step of the drop, with no hitch step (The Quick Game), and an anticipation throw is any throw released before the receiver is open (Quarterback Play).

A short history: Walsh and the three-step game

The modern emphasis on timing owes a lot to Bill Walsh. In 1970 he was an assistant on Paul Brown’s Cincinnati Bengals, and his quarterback, Virgil Carter, was mobile and accurate but had no deep arm. Rather than ask Carter to throw deep, Walsh built a passing game of short, precisely timed throws, many off three-step drops, spread across the whole width of the field, so that a pass could do the work of a running play. Carter led the NFL in completion percentage in 1971.6 Walsh took the approach, much refined, to San Francisco as head coach in 1979 and won three Super Bowls with it; its descendants became the West Coast offense (Offensive Systems and Play-Calling Languages; Case Study: Bill Walsh’s 49ers and the West Coast Offense).

Reads: what the quarterback is looking at

The routes put receivers in places and the drop sets the moment; the quarterback still has to choose. He does it with a read, a decision made after the snap by watching something specific, in the form “if this, then that.” Pass plays use two kinds of read.

  • A progression read asks which receiver is open, looking at receivers one at a time, in order.
  • A defender read asks what is this defender doing, watching one defender and throwing to the receiver he can’t cover.

Most plays mix them. A typical call starts with a defender read on one side (“watch the defender guarding the short area outside; throw the receiver he leaves”), and if both of those receivers are covered, the quarterback moves on to the next receiver in a progression. One check comes before either. If the defense sends more rushers than the line can block, the first answer is the hot route or a receiver’s sight adjust (cutting his route short because he sees the blitz too), thrown at once, before any progression starts.

Progressions

A progression is the order in which the quarterback looks at his receivers. The play call gives him a first read, usually the route the play was designed around; if that receiver is covered, he moves his eyes to the second, then the third, and finally to the checkdown, the short, safe throw to a back or tight end (the outlet) that keeps the play from losing yards. Coaches tie each read to the footwork: the first is thrown on the last step of the drop, the second on the hitch step, the third after a second hitch, so the progression runs on the drop’s clock.

Show the Python
def prog_play(full):
    off = offense("gun_2x2")
    off = [p.moved(w=-14.0) if p.key == "X" else p.moved(w=-7.5) if p.key == "H" else
           p.moved(w=7.5, label="Y") if p.key == "Y" else p.moved(w=14.0) if p.key == "Z" else
           p.moved(w=2.5) if p.key == "RB" else p for p in off]
    dfn = [] if full else [Player("RCB", "CB", "def", 5.5, 16.8, label="CB")]
    p = Play(off, dfn, los=35, to_go=None)
    faded = []
    if full:
        p.dropback(4.0)
        p.route("X", r.post(12))
        p.route("H", r.dig(12, 9))
        p.route("Y", r.curl(12))
        p.route("RB", r.path((2.0, 3.0), (6.5, 4.5)), side=+1)       # checkdown
        faded = [[(-0.7, 14.0), (22.0, 14.0)]]                          # Z: clear-out go
        order = [("1", (24.6, -9.6)), ("2", (12.6, -3.0)), ("3", (12.4, 9.8)), ("4", (1.6, 9.4))]
        drop = ("5-step drop", (-10.5, 0.0))
    else:
        p.dropback(2.5)
        p.route("Y", r.corner(10))
        p.route("Z", r.hitch(5))
        p.route("RB", [(-3.0, 5.0), (0.5, 8.0), (2.0, 11.0)], absolute=True)   # outlet to the flat
        p.highlight("RCB")
        faded = [[(-0.7, -14.0), (1.3, -14.0), (5.0, -9.8)],              # X slant, faded
                 [(-1.5, -7.5), (4.5, -7.5), (4.5, -6.7)]]                # H stick, faded
        order = [("1", (21.6, 13.0)), ("2", (7.4, 11.6)), ("3", (3.6, 9.4))]
        drop = ("3-step drop", (-9.6, 0.0))
    return p, faded, order, drop


fig, axes = plt.subplots(1, 2, figsize=(6.5, 3.95))
for ax, full, ttl in ((axes[0], False, "Half-field: one side, 1-2-3"), (axes[1], True, "Full-field: left to right, 1-4")):
    p, faded, order, drop = prog_play(full)
    fld = p.draw(ax=ax, lateral=(-18.5, 18.5), window=(-12.0, 27.0), title=False)
    no_numbers(ax)
    # small panels: enlarge the labelled skill players (marker and letter) so they read in print
    from matplotlib.patches import Circle, RegularPolygon
    for t in ax.texts:
        lab = t.get_text()
        if t.get_fontweight() == "bold" and 1 <= len(lab) <= 2 and lab != "C":
            t.set_fontsize(t.get_fontsize() * 1.4)
            for pa in ax.patches:
                if isinstance(pa, (Circle, RegularPolygon)) and pa.get_zorder() == 8 and \
                        np.allclose(getattr(pa, "center", getattr(pa, "xy", (0, 0))), t.get_position(), atol=0.05):
                    if isinstance(pa, Circle):
                        pa.set_radius(pa.get_radius() * 1.35)
                    else:
                        pa.radius = pa.radius * 1.35
    for pts in faded:
        faded_route(fld, p, pts)
    for n, at in order:
        x, y = fld.xy(*p.to_abs(*at))
        ax.text(float(x), float(y), n, ha="center", va="center", fontsize=8.5, fontweight="bold", color="white",
                zorder=13, bbox=dict(boxstyle="circle,pad=0.2", fc=style.THIRD, ec="white", lw=0.8))
    tag(fld, p, drop[0], drop[1], fs=6.6)
    ax.set_title(ttl, loc="left", fontsize=8.6, fontweight="bold", color=style.INK)
fig.tight_layout(w_pad=0.5)
plt.show()
Figure 20.6: Two ways to order the quarterback’s looks; the green numbers are the progression. Left, a half-field read off a short drop: the play is a two-man combination on the right, a corner route over a hitch (the pair called smash), read high to low off the ringed cornerback, so the corner route is 1 and the hitch 2, with the back released to the flat as the outlet, 3. The routes on the left (grey) are only there to occupy defenders. Right, a full-field read off a deeper drop: a shot at the post on the left, then the dig and the curl across the middle, then the back as the checkdown. Z’s go on the right has no number: it is there to clear the cornerback out of the way.

Progressions come in two sizes.

  • Half-field reads. The quarterback reads only one side of the field, usually two or three routes built to work together there. The other side’s routes keep defenders busy, or are an emergency answer if the defense blitzes. Because he looks at one side only, the read is quick, which is why it fits a short drop. He picks the side before the snap from what he sees: a receiver alone against a cornerback playing well off him, or the side with fewer defenders than receivers. Most of the quick passing game works this way.
  • Full-field reads. The quarterback starts on one side, often with a deep route, and works across the whole field to the checkdown. A full-field progression gives him more chances to find the one mistake the defense made, but moving his eyes and feet across the field takes a deeper drop and time the rush may not allow.

The trade is speed versus flexibility. A play-caller picks between them partly on the situation and partly on the quarterback: a rookie gets more half-field reads; a veteran with a good line gets the whole field.

What the data says about progressions

Since 2022, the charting company FTN has recorded, for every NFL pass, which read in the progression the ball went to. The nflverse project republishes that charting, so we can see how often quarterbacks really get past their first look, and what each look is worth in EPA, the course’s measure of how much a play changed the offense’s expected points (Downs, Distance, and Field Position): positive EPA left the offense better off than before the snap.

Show the Python
READ_NAMES = {"1": "First read", "2": "A later read", "CHK": "Checkdown", "DES": "Designed throw (screens)",
              "SD": "Scramble drill", "0": "No read (mostly throwaways)"}
rd = (reads[reads.read_thrown.isin(READ_NAMES)]
      .groupby("read_thrown").agg(n=("epa", "size"), epa=("epa", "mean"), ttt=("time_to_throw", "median"),
                                  comp=("complete_pass", "mean")))
rd["share"] = rd.n / rd.n.sum()
rd = rd.loc[["1", "2", "CHK", "DES", "SD", "0"]]
fig, ax = plt.subplots(figsize=(6.5, 2.9))
ys = np.arange(len(rd))[::-1]
cols = [style.SERIES[0], style.SERIES[0], style.SERIES[0], style.MUTED, style.MUTED, style.MUTED]
ax.barh(ys, rd.share, color=cols, height=0.62)
for y, (k, row) in zip(ys, rd.iterrows()):
    ax.text(row.share + 0.008, y, f"{row.share:.0%}   EPA {row.epa:+.2f}   median {row.ttt:.1f} s   "
            f"completed {row.comp:.0%}", va="center", fontsize=6.9, color=style.INK)
ax.set_yticks(ys, [READ_NAMES[k] for k in rd.index], fontsize=7.4)
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0%}"))
ax.set_xlim(0, 1.0)
ax.set_xticks([0, 0.2, 0.4, 0.6])
ax.tick_params(axis="x", labelsize=7)
ax.grid(axis="y", visible=False)
ax.set_xlabel("Share of pass attempts", fontsize=7.4)
ax.set_title("The progression in the data: early reads are quicker and worth more", fontsize=9)
plt.show()
Figure 20.7: Where the ball actually goes in a progression: FTN’s charting of 2022-2025 regular-season pass attempts (sacks excluded) by the read the quarterback threw to. Bar length is the share of attempts; the labels give EPA per attempt and the median time from snap to throw. More than half of all throws go to the first read, and they are both the quickest and the most valuable. Every later look costs time and value; checkdowns are completed most often but are worth about nothing. Designed throws (mostly screens) and scramble-drill throws (made after the play broke down and the quarterback ran around) sit outside the progression. Read codes are interpreted as described in the text.

More than half of all throws go to the first read, and they come out fastest and are worth the most: the first read is the receiver the play was built to get open, thrown on time. Each later look costs about half a second and a good chunk of the value, and the checkdown, completed most often, is worth roughly nothing. That is the case for timing in one chart: the offense is at its best when the plan works the first time.7

Defender reads and the conflict defender

The other kind of read watches one defender instead of a sequence of receivers. The idea is to find a conflict defender, a defender who is responsible for two things at once, and put two receivers in his area. Whatever he does, he is wrong about one of them. You met this idea in What a Play Really Is, where the quarterback on Stick reads the slot defender, and the run game uses it on a defender responsible for both a run gap and a pass zone (Run-Pass Options). Defender reads are fast, because the quarterback looks at one man, not five, and they don’t depend on the receivers beating anyone, only on geometry: a defender can only be in one place. The offense creates the conflict up and down or side to side.

The vertical stretch: high and low

A high-low read (also called a vertical stretch) puts two receivers in one defender’s area at two depths, one above him and one below. The animation below shows the most common version, a curl over a flat route, against the defender who guards both in a Cover 3 zone, the curl-flat defender, who guards the short area outside, from the flat up to the depth of a curl. (Cover 3 has three deep defenders, each guarding a deep third of the field’s width, and four underneath; you’ll meet it properly in Cover 3. For now all that matters is the one defender.)

Show the Python
def high_low(sink):
    off = offense("gun_2x2")
    off = [p.moved(w=8.5, label="Y") if p.key == "Y" else p.moved(w=15.5) if p.key == "Z" else p for p in off]
    dfn = defense("nickel", "single_high", off)
    mv = {"RCB": (7.5, 16.0), "NB": (4.5, 10.0), "MIKE": (4.5, 2.0), "FS": (13.0, 0.0), "SS": (5.0, -11.0),
          "LCB": (7.5, -16.0), "WILL": (4.5, -4.0)}
    dfn = [p.moved(d=mv[p.key][0], w=mv[p.key][1]) if p.key in mv else p for p in dfn if p.key != "DB"]
    dfn = [p.moved(label="CB") if p.pos == "CB" else p for p in dfn]
    p = Play(off, dfn, los=35, to_go=10)
    protect(p)
    p.dropback(2.5)
    p.path("QB", [(0.5, 0.0)], speed=2.0)
    p.route("Z", r.path((12.5, 0.0), (11.2, -1.3)))                     # curl: high
    p.route("Y", r.path((1.0, 3.0), (2.4, 7.5), (3.2, 12.0)), speed=6.4)  # flat: low
    p.path("NB", [(6.5, 11.5)], absolute=True, speed=3.5)               # first steps: shuffle, read
    if sink:
        p.path("NB", [(10.0, 14.0)], absolute=True, speed=4.5)          # sinks under the curl
        p.pass_to("Y", t=1.3)
    else:
        p.path("NB", [(3.8, 17.0)], absolute=True, speed=6.5)           # jumps the flat
        p.pass_to("Z", t=1.7)
    p.path("RCB", [(16.0, 17.0)], absolute=True, speed=5.2)             # deep third
    p.drop("MIKE", (9.0, 4.0))
    p.route("X", r.go(18))                                              # the left side: just occupying
    p.route("H", r.seam(16))                                            # defenders on the web animation
    p.drop("LCB", (15.0, -16.0))
    p.drop("WILL", (8.5, -4.0))
    p.drop("SS", (7.0, -12.0))
    p.drop("FS", (17.0, 0.5))
    p.highlight("NB")
    return p


HL_SINK, HL_JUMP = high_low(True), high_low(False)
if PDF:
    with ball_speed(22):
        strip([HL_SINK, HL_JUMP], [0.7, 1.3, 2.35, 0.7, 1.7, 2.7],
              ["+0.7 s: $ reads the QB", "+1.3 s: $ sinks; ball to the flat", "+2.35 s: Y catches it",
               "+0.7 s: the same start", "+1.7 s: $ jumps the flat", "+2.7 s: Z catches the curl"],
              lateral=(-2, 26.6), window=(-9.5, 18.5), rings=("NB",), rulers=False,
              notes={1: [("high", (14.3, 11.6)), ("low", (1.8, 23.2))],
                     4: [("high", (14.3, 11.6)), ("low", (1.8, 23.2))]})
        plt.show()
else:
    with ball_speed(22):
        show_videos([HL_SINK, HL_JUMP], ["He sinks: throw the flat", "He jumps the flat: throw the curl"])
Figure 20.8: A high-low read of one defender, both branches (simulated play, Cover 3, the right half of the field). Z runs a curl at about 12 yards (high); Y runs to the flat (low). The ringed nickel defender ($) is responsible for both areas, so the quarterback watches only him. Top row: he sinks under the curl, so the ball goes to the flat. Bottom row: he jumps the flat, so the ball goes to the curl, behind him and in front of the deep-third cornerback. Times are seconds after the snap.

The ringed nickel’s zone runs from the sideline in toward the hash and from the line out to about 12 yards: two receivers’ worth of field. If he gets depth to stand under the curl, the flat route is alone in front of him, and the ball goes there for a few easy yards with room to run. If he comes forward to cover the flat route, the curl settles in the hole behind him, in front of the cornerback, who is busy guarding the deep third, and the ball goes there for 10 or 12. The quarterback doesn’t need the whole coverage, only one man.

Why does this work? A zone defender is taught to stay between the ball and the deepest threat in his area, so one who sinks under the curl is doing his job; he is just losing the flat. The defense has three good answers. It can change who covers what: in man coverage, each receiver has his own defender, and there’s no one to put in conflict. That is why offenses want a hint of man or zone before they choose a read, and the defense gives some: the safeties’ shell from A First Look at Defense, and cornerbacks who line up tight, eyes on a receiver, rather than deep and watching the quarterback (Coverage Fundamentals and What Beats What build this out). It can pattern-match: zone defenders pick up a receiver who enters their area and stay with him, so the curl-flat defender takes the flat and a linebacker gets under the curl (Cover 3). Or it can bait: show a soft flat, then drive on it once the ball is in the air.

The high-low idea is everywhere. In Cover 2, two safeties split the deep field in halves and the cornerbacks stay short, guarding the flats; a corner route over a hitch puts that cornerback in the same bind (that is the concept called smash), and three receivers at three depths on one sideline make it a three-level stretch (the flood concept). Both are built in full in Dropback Concepts.

The horizontal stretch: inside and outside

A horizontal stretch does the same thing side to side: it puts receivers on both sides of an underneath defender at about the same depth, so he can’t stand in front of both.

Show the Python
off = offense("gun_2x2")
off = [p.moved(w=-16.0) if p.key == "X" else p.moved(w=-8.5) if p.key == "H" else
       p.moved(w=8.5, label="Y") if p.key == "Y" else p.moved(w=15.5) if p.key == "Z" else
       p.moved(w=-2.4) if p.key == "RB" else p for p in off]
dfn = defense("nickel", "single_high", off)
mv = {"LCB": (8.0, -16.5), "RCB": (8.0, 16.0), "FS": (13.0, 0.0), "SS": (5.5, -11.0),
      "WILL": (4.5, -4.0), "MIKE": (4.5, 1.5), "NB": (4.5, 10.0)}
dfn = [p.moved(d=mv[p.key][0], w=mv[p.key][1], label="CB" if p.pos == "CB" else None) if p.key in mv else p
       for p in dfn if p.key != "DB"]
hz = Play(off, dfn, los=35, to_go=8)
protect(hz)
hz.dropback(2.5)
hz.route("X", r.hitch(5))
hz.route("H", r.path((5.5, -0.8), (5.5, -0.2)))
hz.route("RB", [(-1.0, -2.5), (5.5, -2.5)], absolute=True)    # back checks for a blitzer, then sits left of M
hz.route("Y", r.path((5.5, 2.5), (5.5, 3.0)), side=-1)        # Y sits right of M
hz.route("Z", r.hitch(5))
for k, to in (("LCB", (14.0, -16.5)), ("RCB", (14.0, 16.0)), ("FS", (15.5, 0.0)),
              ("SS", (6.5, -12.0)), ("WILL", (7.0, -5.0)), ("MIKE", (7.0, 1.6)), ("NB", (6.5, 11.5))):
    hz.drop(k, to)
hz.highlight("MIKE")
fld = hz.draw(lateral=(-21.5, 21.5), window=(-8, 16.5), title=False)
no_numbers(fld.ax)
ruler(fld, hz, top=15)
dotted(fld, hz, [(7.0, 1.6), (7.4, 4.5)], color=style.DEFENSE, lw=1.9, head=13)
dotted(fld, hz, [(7.0, 1.6), (7.4, -1.0)], color=style.DEFENSE, lw=1.9, head=13)
tag(fld, hz, "M leans to Y:\nthrow to R", (10.6, 6.8), fs=6.6)
tag(fld, hz, "M leans to R:\nthrow to Y", (10.6, -4.0), fs=6.6)
plt.show()
Figure 20.9: A horizontal stretch. Five receivers settle at 4-6 yards, spread across the field, against the four underneath zone defenders of a Cover 3 defense (dashed arrows are their drops; the two cornerbacks and the free safety drop into the three deep zones). Every underneath defender has a receiver on each side of him. The quarterback watches the ringed Mike, who sits between the back (R) on his left and Y on his right: whichever way he leans (dotted), the man on the other side is open.

The arithmetic is the heart of it. Eleven defenders, minus four rushers, minus the deep defenders, leaves the underneath ones: 11 − 4 − 3 = 4 in Cover 3, 11 − 4 − 2 = 5 in Cover 2. Put five receivers across the field at the same depth against four underneath defenders, and at least one receiver has nobody in front of him while every defender has a receiver on each side. The Mike in the figure stands in his hook zone, the area just past the line in the middle, with the back sitting on one side of him and Y on the other. If he leans toward Y, the back is open; if he leans toward the back, Y is.

Horizontal stretches work underneath, in front of the deep defenders, so they are short, reliable and quick: the backbone of the quick game’s spacing and stick concepts (The Quick Game). What they give up is depth; the big gains have to come after the catch. Combine one with a vertical stretch and you get three receivers around one defender, the triangle read of Dropback Concepts.

ImportantWhy it exists

Stretches beat zone coverage without winning a one-on-one race. Zone defenders guard areas, and areas have edges: put two receivers in one defender’s area, above and below him or on either side, and he has to choose. It is Gillman’s principle of making the defense cover the whole field, applied one defender at a time. Against man coverage there is no area to overload, so the answers are different: routes that win one-on-one, double moves, and crossing routes that make defenders run into each other (the man-beaters of Dropback Concepts). That is why “man or zone?” sits behind every read.

Measuring a throw: air yards, YAC and aDOT

A completion’s yards come from two places, the ball’s flight and the receiver’s run after the catch. Analytics splits them with three numbers you’ll meet on broadcasts and in almost every article about a quarterback.

  • Air yards measure how far past the line of scrimmage the ball traveled in the air, to the spot where it was caught, or where the target was when it fell incomplete. Only the distance downfield counts, not the sideways distance. A screen caught 3 yards behind the line has −3 air yards.
  • YAC, yards after catch, is what the receiver adds from the catch to the end of the play.
  • aDOT, average depth of target, is the average air yards per pass, for a quarterback (per throw) or a receiver (per target), completed or not. It describes where a quarterback throws or a receiver is used, not how well.

An example. A slant caught 6 yards past the line, and run 14 more: a 20-yard gain, 6 air yards and 14 YAC. An incomplete deep out aimed 14 yards downfield: no gain, but 14 air yards toward the quarterback’s aDOT. Across the NFL in 2025 the average throw traveled about 7.8 yards past the line, and about 47% of all receiving yards came after the catch, so the two halves are close to equal.8

Show the Python
dv = passes.groupby("depth", observed=True).agg(n=("epa", "size"), comp=("complete_pass", "mean"),
                                                epa=("epa", "mean"), succ=("success", "mean"),
                                                intr=("interception", "mean"))
dv["share"] = dv.n / dv.n.sum()
cmp_ = passes[passes.complete_pass == 1]
dv["air"] = cmp_.groupby("depth", observed=True).air_yards.mean()
dv["yac"] = cmp_.groupby("depth", observed=True).yards_after_catch.mean()
xs = np.arange(len(dv))
fig, axes = plt.subplots(2, 2, figsize=(6.5, 4.9), sharex=True)
specs = [(axes[0, 0], "share", "Share of all attempts", lambda v: f"{v:.0%}"),
         (axes[0, 1], "comp", "Completion rate", lambda v: f"{v:.0%}"),
         (axes[1, 0], "epa", "EPA per attempt", lambda v: f"{v:+.2f}")]
for ax, col, ttl, fmt in specs:
    vals = dv[col].values
    cols = [style.SERIES[0] if v >= 0 else style.SERIES[1] for v in vals]
    ax.bar(xs, vals, color=cols, width=0.7)
    for x, v in zip(xs, vals):
        ax.text(x, v + (0.012 if col != "epa" else (0.015 if v >= 0 else -0.015)) * (1 if col != "share" else 0.6),
                fmt(v), ha="center", va="bottom" if v >= 0 else "top", fontsize=6.6, color=style.INK)
    ax.set_title(ttl, fontsize=8.6)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _, c=col: f"{v:.0%}" if c != "epa" else f"{v:+.1f}"))
    ax.tick_params(labelsize=7)
for x, v, c in zip(xs, dv.intr.values, dv.comp.values):                # interception rate inside the bar
    axes[0, 1].text(x, 0.04, f"INT\n{v:.0%}", ha="center", va="bottom", fontsize=5.6, color="white",
                    fontweight="bold", linespacing=0.95)
axes[1, 0].axhline(0, color=style.BASELINE, lw=1)
axes[1, 0].set_ylim(-0.3, 0.55)
axes[0, 1].set_ylim(0, 0.92)
axes[0, 0].set_ylim(0, 0.31)
ax = axes[1, 1]
ax.bar(xs, dv.air, color=style.SERIES[0], width=0.7, label="air yards")
ax.bar(xs, dv.yac, bottom=dv.air.clip(lower=0), color=style.SERIES[2], width=0.7, label="yards after catch")
for x, a, y in zip(xs, dv.air, dv.yac):
    ax.text(x, max(a, 0) + y + 0.8, f"{a + y:.0f}", ha="center", va="bottom", fontsize=6.6, color=style.INK)
ax.axhline(0, color=style.BASELINE, lw=1)
ax.set_ylim(-6, 48)
ax.set_title("Yards per completion: in the air + after the catch", fontsize=8.6)
ax.legend(fontsize=6.8, loc="upper left")
ax.tick_params(labelsize=7)
for a in axes[1]:
    a.set_xticks(xs, list(dv.index), fontsize=6.8)
    a.set_xlabel("Target depth (air yards)", fontsize=7.2)
fig.tight_layout(h_pad=0.8, w_pad=1.0)
plt.show()
Figure 20.10: What each depth of throw is worth, 2021-2025 regular seasons (all pass attempts with a charted target depth; sacks excluded). Short throws are the most common and the easiest to complete, but a completion behind the line of scrimmage loses expected points on average. Completion rate falls steadily with depth while the interception rate (printed inside each completion bar) climbs, yet EPA per attempt rises through about 15-19 yards and then levels off: the deep ball fails often but pays a lot when it hits. The bottom-right panel splits the yards on a completion into the air yards and the yards after the catch.

The chart is the passing game’s central trade. Short throws are reliable but don’t gain much in the air, so they live on YAC. Throws behind the line of scrimmage, mostly screens and flips to backs, are completed four times in five, and a completed one gains about 6 yards (about three lost in the air, nine gained after the catch). Yet as a group they lose expected points, for two reasons. The one in five that falls incomplete wastes a down, and a six-yard gain is only modest progress: on first-and-10, a four-yard gain averages -0.16 EPA and it takes about five yards to break even (+0.02).9 That is stricter than the old yardage rule of thumb you met in Downs, Distance, and Field Position, which counts four yards (40% of the ten needed) as a success; the EPA version says second-and-6 is, on average, a little worse than first-and-10. Throws of 5–9 yards are the most dependable winners: completed about two times in three, they have the highest success rate of any depth (59%). Deep throws fail most of the time and pay anyway. Throws of 20 yards or more are completed less than half the time and are intercepted on about 6% of attempts, against 2% for everything shorter, yet each attempt is worth about as much as a throw of 10–19 yards, because a completion moves the ball so far. The bottom-right panel shows a surprisingly steady 3–6 yards after the catch on anything thrown past the line.

CautionCommon misconception

“High aDOT good, low aDOT bad.” aDOT describes a style, not a level of play. A quarterback in a quick, horizontal offense built on YAC can have a low aDOT and an excellent season; a quarterback with a high aDOT may be taking shots because his team is always behind. YAC has the same problem. It depends on the play design (screens and crossing routes produce YAC; comebacks and corners rarely do), on the receiver, and on the throw itself, since a ball placed in stride lets a receiver keep running. To judge a passer, the course uses measures built on top of these, such as CPOE (completion percentage over expected, which accounts for depth) in Quarterback Play, and EPA per play.

The same split shows up in receivers. The table lists the 2025 receivers whose targets were deepest and the ones who gained the most after each catch.

Show the Python
p25 = passes[passes.season == 2025]
rec = (p25.groupby(["receiver_player_id", "receiver_player_name"])
       .agg(team=("posteam", "last"), targets=("air_yards", "size"), adot=("air_yards", "mean"),
            catches=("complete_pass", "sum"), yac=("yards_after_catch", "sum"))
       .reset_index())
rec = rec[rec.targets >= 80]
rec["yac_per"] = rec.yac / rec.catches
hi_adot = rec.nlargest(5, "adot")
hi_yac = rec.nlargest(5, "yac_per")
nm = lambda row: f"{row.receiver_player_name.replace('.', '. ', 1)} ({row.team})"  # noqa: E731
lines = ["| Highest aDOT | aDOT | YAC/catch | Most YAC per catch | aDOT | YAC/catch |",
         "|:---|---:|---:|:---|---:|---:|"]
for (_, a), (_, b) in zip(hi_adot.iterrows(), hi_yac.iterrows()):
    lines.append(f"| {nm(a)} | {a.adot:.1f} | {a.yac_per:.1f} | {nm(b)} | {b.adot:.1f} | {b.yac_per:.1f} |")
LG_ADOT, LG_YAC = p25.air_yards.mean(), p25.loc[p25.complete_pass == 1, "yards_after_catch"].mean()
lines.append(f"| *League average* | *{LG_ADOT:.1f}* | *{LG_YAC:.1f}* | | | |")
print("\n".join(lines))
print("\n: Two kinds of pass catcher, 2025 regular season: players at any position with at least 80 targets with a charted "
      "depth. aDOT is the average air yards per target; YAC/catch is the average yards gained after the catch. "
      "{#tbl-adot-yac}\n")
Table 20.1: Two kinds of pass catcher, 2025 regular season: players at any position with at least 80 targets with a charted depth. aDOT is the average air yards per target; YAC/catch is the average yards gained after the catch.
Highest aDOT aDOT YAC/catch Most YAC per catch aDOT YAC/catch
A. Pierce (IND) 19.0 3.7 B. Robinson (ATL) 1.0 10.8
B. Thomas (JAX) 14.5 3.3 D. Achane (MIA) 0.4 8.1
R. Odunze (CHI) 13.9 4.9 J. Gibbs (DET) 0.6 7.9
T. Higgins (CIN) 13.4 2.8 K. Gainwell (PIT) -0.2 7.2
J. Waddle (MIA) 13.1 3.6 K. Shakir (BUF) 3.7 7.1
League average 7.8 5.1

The two columns are different jobs. The deep-aDOT players are mostly outside receivers on posts, gos and comebacks, who gain little after the catch because a ball caught 20 yards downfield usually arrives with a defender close by. The YAC leaders are mostly running backs caught at or behind the line in open field, where they can make a defender miss. A passing game needs both.

Film room

NoteFilm room: 49ers 1988 season, Super Bowl XXIII — a progression to the third option

January 22, 1989, Miami. The Bengals led the 49ers 16–13 with a little over three minutes left, and San Francisco took over at its own 8-yard line. Joe Montana drove them 92 yards in 11 plays, mostly on short and intermediate throws: Walsh’s timing game, under pressure. On the final play, from the Bengals’ 10 with 39 seconds left, the call, “20 Halfback Curl, X-Up,” was designed for the halfback, Roger Craig, with Jerry Rice in motion to clear out the other side. Craig was held up in traffic, so Montana moved on in his progression and found John Taylor in the middle of the end zone for the touchdown with 34 seconds left. San Francisco won 20–16.10

What to notice: the winning throw went to the receiver the play was not designed for. That is what a progression is for: the first read was taken away, and the quarterback had a pre-set order of where to look next and the time to look there. It was Walsh’s last game as head coach, and the best-known demonstration of his offense: short, precise, on time, and with an answer when the first answer was covered.

NoteFilm room: Buccaneers and Chiefs 2022 — rhythm against improvisation

The 2022 season gives the cleanest contrast in modern quarterback timing: Tom Brady’s last season in Tampa Bay and Patrick Mahomes’s second MVP season in Kansas City, two prolific passers on very different clocks.

Show the Python
q22 = reads[reads.season == 2022]
BM = {}
for _qb in ("T.Brady", "P.Mahomes"):
    _z = q22[q22.passer_player_name == _qb]
    _late = _z[_z.time_to_throw >= 3.5]
    BM[_qb] = {"lt25": (_z.time_to_throw < 2.5).mean(), "first": (_z.read_thrown == "1").mean(),
               "late": len(_late) / len(_z), "late_epa": _late.epa.mean(),
               "late_oop": ((_late.is_qb_out_of_pocket == True) | (_late.read_thrown == "SD")).mean()}  # noqa: E712
fig, ax = plt.subplots(figsize=(6.5, 2.9))
edges = np.arange(0.5, 6.01, 0.25)
for lab, sel, col, ls in (("League", q22, style.MUTED, (0, (3, 2))),
                          ("T. Brady (TB)", q22[q22.passer_player_name == "T.Brady"], style.SERIES[1], "-"),
                          ("P. Mahomes (KC)", q22[q22.passer_player_name == "P.Mahomes"], style.SERIES[0], "-")):
    tt = sel.time_to_throw.dropna().clip(upper=5.99)
    ax.hist(tt, bins=edges, weights=np.full(len(tt), 1 / len(tt)), histtype="step", lw=2 if ls == "-" else 1.4,
            color=col, ls=ls, label=f"{lab}: median {tt.median():.2f} s")
ax.axvline(3.5, color=style.BASELINE, lw=1)
ax.text(3.55, ax.get_ylim()[1] * 0.92, "3.5 s", fontsize=7, color=style.INK_2)
ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0%}"))
ax.set_xlabel("Seconds from snap to throw (throws after 6 s piled into the last bin)", fontsize=7.4)
ax.set_ylabel("Share of attempts", fontsize=7.4)
ax.tick_params(labelsize=7)
ax.set_title("Brady threw in rhythm; Mahomes also won late", fontsize=9)
ax.legend(fontsize=7, loc="center right")
plt.show()
Figure 20.11: Two ways to beat the clock, 2022 regular season (Brady’s last season, Mahomes’s second MVP season). Each line is the share of a quarterback’s pass attempts thrown in each quarter-second after the snap; the dashed grey line is the league. Brady’s throws pile up early, in rhythm. Mahomes throws on time too, but he has a long right-hand tail: about a quarter of his throws left after 3.5 seconds, most of them when the play had moved past its design, and they still produced positive EPA.

Brady was the league’s model of a rhythm passer: 55% of his throws left in under 2.5 seconds, and 60% went to his first read. Mahomes threw on time too, but 26% of his throws came after 3.5 seconds, mostly after he had left the pocket or the play had broken down, and those late throws still averaged +0.31 EPA, better than the league’s average throw.11

What to notice: two answers to the same clock. Brady beat the rush by being done before it arrived. Mahomes beat it by keeping the play alive after the design ran out, throwing on the run, off one foot or sidearm to receivers taught where to go when the play broke down (the scramble drill of Quarterback Play). Watch a replay of either and count: on a Brady throw you can usually match the release to the end of the drop; on many Mahomes throws, the drop is long over.

TipWatch for it on Sunday: be the route caller

Pick one drive and call every pass play three times: before the snap, at the snap, and on the replay.

  • Before the snap: count and number. How many receivers on each side? Number them from the sideline in: #1, #2, #3 (Spread Formations). Note the splits and the hash: a receiver tight to the formation, or on the wide side, has room to break outside (corners, outs); one near the sideline mostly breaks inside (slants, digs, posts).
  • At the snap: watch the quarterback’s feet. From under center, count three, five or seven steps; from the shotgun, one, three or five, starting after he catches the snap. Three (or one) means the ball should be out at once to a 5-yard route; five (or three) means a 10–14-yard route; seven (or five), or extra hitch steps, means deep routes or a quarterback working past his first read.
  • Then his head. If it stays on one side and the ball goes to one of two receivers near the same defender, that was a defender read. If it snaps from receiver to receiver, often with his feet resetting each time, and the ball goes somewhere he wasn’t looking first, he moved through a progression.
  • On the replay: name every route. Use the wide angle that shows the receivers and the deep defenders. Name and number each route (“Z ran a 9, H a 5 at twelve, Y a flat, X a slant”), then find the stretch: which defender had two receivers in his area, which way did he go, and where did the ball go?
  • In the stats: lots of yards per catch could mean deep targets or big YAC; the box score can’t tell you which. Advanced stat pages (Pro Football Reference, Next Gen Stats) list aDOT and YAC separately, and many broadcasts show Next Gen Stats graphics (the NFL’s player-tracking numbers) with air yards and time to throw.

Predict the play

TipPredict the play 1: count the steps
Show the Python
off = [p for p in offense("singleback") if p.key not in ("H", "X")]
off = [p.moved(d=-6.5, w=-2.2) if p.key == "RB" else p for p in off]
dfn = [p for p in defense("4-3_over", "single_high", off) if p.key in ("SDE", "SDT", "WDT", "WDE", "SAM", "MIKE", "WILL", "RCB", "FS", "SS")]
dfn = [p.moved(d=8.0, w=15.5, label="CB") if p.key == "RCB" else p for p in dfn]
pr1 = Play(off, dfn, los=45, to_go=10)
pr1.dropback(5.5)
pr1.highlight("Z")
fld = pr1.draw(lateral=(-9, 26.6), window=(-9.5, 27), title=False)
no_numbers(fld.ax)
ruler(fld, pr1, top=25)
z = pr1.players["Z"]
dotted(fld, pr1, [(z.d, z.w), (6.0, z.w), (4.6, z.w - 1.4)])
dotted(fld, pr1, [(z.d, z.w), (12.0, z.w), (12.0, z.w + 6.5)])
dotted(fld, pr1, [(z.d, z.w), (14.0, z.w), (24.0, z.w - 6.5)])
tag(fld, pr1, "A", (5.0, z.w - 2.4), bold=True)
tag(fld, pr1, "B", (13.6, z.w + 6.0), bold=True)
tag(fld, pr1, "C", (24.0, z.w - 8.4), bold=True)
tag(fld, pr1, "5-step drop", (-8.0, 3.2), fs=6.6)
plt.show()
Figure 20.12: Second-and-10. The quarterback takes the snap under center and drops back: his marker shows where he starts, and the dotted line behind it is his drop, five steps, about seven yards. The cornerback across from Z plays 8 yards off. Z’s three possible routes are lettered: A, a hitch at 5 yards; B, an out at 12; C, a post breaking at 14.

You can’t see Z’s route yet, but you can see the quarterback’s feet. Which of Z’s three routes is the ball most likely meant for, and roughly when should it come out?

B, the out at 12 yards, thrown at about 1.8–2 seconds, around the moment Z breaks. A five-step drop from under center ends about 7 yards deep, and it is the drop paired with routes that break at 10–14 yards: outs, curls, digs. The off cornerback is the trap. Eight yards of cushion (the space he gives the receiver) invites the hitch, but the hitch (A) goes with a three-step drop; by the time the quarterback finishes five steps, a 5-yard hitch has been sitting still for a second and the cornerback has had time to close on it. If the offense wanted the hitch against that cushion, it would have called a three-step drop. The post (C) needs another second to get open deep; today it is usually a later read off this drop or a shot off play-action, not the first throw. The cushion still helps the out: a cornerback backing up leaves room in front of him for a ball thrown to the sideline on time. Footwork is not a guarantee (he can hitch and work to a deeper second read), but the drop tells you where the plan was.

TipPredict the play 2: the cornerback who stopped
Show the Python
off = offense("gun_2x2")
off = [p.moved(w=-16.0) if p.key == "X" else p.moved(w=-9.0) if p.key == "H" else p for p in off]
dfn = defense("nickel", "two_high", off)
mv = {"LCB": (5.0, -17.5), "FS": (12.5, -10.0), "WILL": (4.5, -5.0), "MIKE": (4.5, 1.5), "NB": (4.5, 8.0),
      "RCB": (5.5, 15.0), "SS": (12.5, 10.0)}
dfn = [p.moved(d=mv[p.key][0], w=mv[p.key][1], label="CB" if p.pos == "CB" else None) if p.key in mv else p
       for p in dfn if p.key != "DB"]
pp = Play(off, dfn, los=40, to_go=8)
protect(pp)
pp.dropback(2.5)
pp.route("H", r.path((11.5, 0.0), (20.0, 11.0)))                    # high: a real smash corner
pp.block("RB", pts=[(0.8, -1.5)])
pp.route("X", r.hitch(5))                                           # low
pp.path("LCB", [(3.4, -18.6)], absolute=True, speed=3.0)          # squats on the hitch, outside leverage
pp.drop("FS", (16.0, -11.0))
pp.drop("WILL", (8.5, -6.0))
pp.drop("MIKE", (10.0, 0.0))
pp.highlight("LCB")
snapshot(pp, 1.9, lateral=(-26.6, 6), window=(-9, 23), show_ruler=True)
plt.show()
Figure 20.13: Second-and-8, 1.9 seconds after the snap, against Cover 2 (two deep safeties; the cornerbacks play the short zones near the sideline). On the left, X has run a 5-yard hitch and the slot (H) has pushed to about 10 yards and is breaking toward the sideline on a corner route. The ringed cornerback has stopped about 4 yards deep, just outside X and in front of the hitch, squatting on it; the safety on that side is in the middle of his deep half, 16 yards deep. The ball is drawn beside the quarterback, who still has it.

The quarterback is reading the ringed cornerback. Where should the ball go, and why does it have to go now?

The corner route, to H, thrown now toward the sideline about 18 yards downfield. This is a high-low read of the cornerback: X’s hitch is the low route, H’s corner route the high one. In Cover 2 the cornerback guards the short area near the sideline, and he has done his job, squatting on the hitch. That leaves the area above him empty until the safety, 16 yards deep in the middle of his half, can run over and back to it, so the window is open only for a moment: the ball has to leave as H breaks, over the cornerback’s head and in front of the safety. Had the cornerback sunk under the corner route, the hitch would have been open, and on second-and-8 a 5-yard hitch is a fine result. This pairing is the smash concept, one of the oldest answers to Cover 2 (Dropback Concepts).

TipPredict the play 3: name the routes
Show the Python
off = offense("gun_trips")
off = [p.moved(d=-1.5, w=6.5) if p.key == "Y" else p.moved(d=-0.7) if p.key == "Z" else p for p in off]
off = [p.moved(w=17.0) if p.key == "Z" else p.moved(w=11.0) if p.key == "H" else
       p.moved(w=-2.4) if p.key == "RB" else p for p in off]
pr3 = Play(off, [], los=30, to_go=10)
pr3.route("Z", r.go(24))
pr3.route("H", r.out(13.5, 9.0))
pr3.route("Y", r.path((2.0, 3.5), (3.5, 13.0)))
pr3.route("X", r.slant(2, 8))
pr3.block("RB", pts=[(0.5, 1.0)])
pr3.dropback(2.5)
fld = pr3.draw(lateral=(-20, 25), window=(-7.5, 25.5), title=False)
no_numbers(fld.ax)
ruler(fld, pr3, top=25)
for key, at in (("Z", (24.0, 19.5)), ("H", (13.8, 22.2)), ("Y", (3.6, 21.8)), ("X", (7.3, -7.0))):
    tag(fld, pr3, "?", at, bold=True)
plt.show()
Figure 20.14: Name the routes. Trips right (Z is #1, H #2 and the tight end Y #3), X alone on the left; the back stays in to block. The defense is not drawn: assume Cover 3.

Name each route and give its number in the tree from this chapter. Assuming the defense is in Cover 3, which defender is the play designed to put in conflict, and how should the quarterback read him?

Z: a go route (9). H: an out (5) at about 12 yards. Y: a flat route (1). X, on the backside: a slant (2). The three routes on the right are a three-level vertical stretch along one sideline. Z’s go carries the cornerback deep (if he stays, Z is open deep). That leaves the out and the flat as a high-low of the curl-flat defender, often the nickel: he is the conflict defender. If he sinks under the out, the flat is open in front of him; if he comes up for the flat, the out is open behind him. This combination is the flood concept (also called sail). X’s slant is the backside answer: if the defense shifts extra defenders toward the three receivers, or blitzes, X is alone on the other side, and the quick slant goes to the side the defense left.

Takeaways

  • A route has a stem and a break, and the break is where it wins. The route tree (1 flat, 2 slant, 3 comeback, 4 curl, 5 out, 6 dig, 7 corner, 8 post, 9 go; odd out, even in) is a shared vocabulary whose numbers vary by team; the seam, wheel, whip, sluggo and option route live off it.
  • The drop is a clock. Three, five and seven steps from under center (one, three and five from the shotgun) end at about 5, 7 and 9 yards and pair with routes at 5–6, 10–14 and 15–20 yards. Matched, the ball leaves as the receiver breaks; a timing route is thrown to a spot before he gets there.
  • Reads come in two kinds. A progression checks receivers in order, over half the field (fast) or the whole field (flexible, slower); a defender read watches one conflict defender and throws where he isn’t. First reads are the quickest and most valuable throws.
  • Stretch the defender, up and down or side to side. A high-low puts receivers above and below one defender; a horizontal stretch puts them on either side. Both beat zone by geometry; against man the offense needs different answers.
  • Short throws live on YAC, deep throws on air yards. Air yards, YAC and aDOT describe where an offense gains its yards, not how good it is. Throws behind the line lose value on average, 5–9-yard throws succeed most often, and deep throws fail more but pay.

Connections

Builds on: What a Play Really Is (a play is rules, not paths; the first read), Spread Formations (numbering receivers, splits and the two-way go) and A First Look at Defense (man and zone, one-high and two-high).

Sets up: the rest of Part 4: Pass Protection (buying the time), The Quick Game, Dropback Concepts (smash, flood, levels, mesh), Play-Action, Bootlegs, and Screens, Run-Pass Options and Quarterback Play. On defense, Coverage Fundamentals names the zones these routes attack, and What Beats What puts the two sides together. Case study: Case Study: Bill Walsh’s 49ers and the West Coast Offense.

NoteGo deeper
  • Bill Walsh with Brian Billick and James Peterson, Finding the Winning Edge (1997): Walsh’s own long manual on building and coaching a team, including his offensive philosophy and passing game (out of print and hard to find).
  • Chris B. Brown, The Essential Smart Football (2012) and The Art of Smart Football (2015): short, clear essays on route combinations, stretches and the history of the passing game.
  • Tim Layden, Blood, Sweat and Chalk (2010): the stories, told largely by the coaches themselves, behind Air Coryell, the West Coast offense and some twenty other schemes.
  • Matt Bowen’s route-tree primer at the National Football Post (cited below) and his later columns for ESPN: a former NFL defensive back explaining routes from the defender’s side.

  1. Course calculation from nflverse play-by-play joined to load_participation()’s time_to_throw (“duration (in seconds) between the time of the ball being snapped and the time of release of a pass attempt”), 2025 regular season, pass attempts with a recorded time (sacks excluded). Participation data come from NFL Next Gen Stats through 2022 and from FTN from 2023, so times from different eras are not strictly comparable. Dictionary: https://nflreadr.nflverse.com/articles/dictionary_participation.html↩︎

  2. “Air Coryell,” Wikipedia: https://en.wikipedia.org/wiki/Air_Coryell (Coryell’s 12 seasons at San Diego State before the NFL; his offense “based on Sid Gillman’s offense, which required the defense to defend the entire field”; the Chargers led the league in passing yards from 1978 to 1983 and in 1985). Matt Bowen, “Inside the playbook: the NFL route tree,” National Football Post (June 30, 2011): https://www.nationalfootballpost.com/inside-the-playbook-the-nfl-route-tree (1 flat, 2 slant, 3 comeback, 4 curl, 5 out, 6 dig, 7 corner, 8 post, 9 fade or go, with typical depths). Pro Football Hall of Fame biographies of Gillman (Chargers head coach through the AFL decade, 1960–1969) and Coryell (San Diego State 1961–1972, Cardinals 1973–1977, Chargers 1978–1986): https://www.profootballhof.com/players/sid-gillman/ and https://www.profootballhof.com/players/don-coryell/ . The credit for numbering routes rests on the coaching tradition rather than on a documented first use; Jason Garrett, a Coryell-system play-caller, describes “the three-digit system of digitizing the routes” in Calvin Watkins, “Don Coryell’s offense is Jason Garrett’s,” ESPN Dallas (July 2, 2010): https://www.espn.com/blog/dallas/cowboys/post/_/id/4669232/don-coryells-offense-is-jason-garretts . The digit order (split end, tight end, flanker) and the example call “896 H-Shallow F-Curl”: “Play calling system,” Wikipedia: https://en.wikipedia.org/wiki/Play_calling_system↩︎

  3. Doug Farrar, “No. 72: Julian Edelman,” Sports Illustrated (July 12, 2016): “Over the last few years, Edelman has replaced Wes Welker as the best option route runner in the NFL”: https://www.si.com/nfl/2016/07/12/julian-edelman-patriots-nfl-top-100 . For the Patriots’ system more broadly, Chris B. Brown, “Speak My Language,” Grantland (Jan. 25, 2013): https://grantland.com/features/how-terminology-erhardt-perkins-system-helped-maintain-dominance-tom-brady-patriots/↩︎

  4. Steve Palazzolo, “QBs in Focus: Drop-back Depth,” PFF (June 7, 2014): “the 4-6 yard range is generally the 3-step game (1 step from shotgun), the 7-8 yard range corresponds to a 5-step drop (3 steps from shotgun), and finally the 9+ yard range is more of a 7-step drop”: https://www.pff.com/news/qbs-in-focus-drop-back-depth . Exact depths vary by coach and quarterback.↩︎

  5. Course calculation from nflverse 2025 regular-season play-by-play: the share of dropbacks with shotgun == 1 (nflverse marks pistol snaps as shotgun). FTN charting’s quarterback-location field gives a similar picture (under center on about a third of all 2025 snaps, runs included); see planning/FACTS-current.md §10.↩︎

  6. “West Coast offense,” Wikipedia: https://en.wikipedia.org/wiki/West_Coast_offense (Walsh developed the approach as a Bengals assistant under Paul Brown, 1968–1975; Carter, mobile and accurate but without a strong arm, “would be the first player to successfully implement Walsh’s system, leading the NFL in pass completion percentage in 1971”; the three-step drop). “Bill Walsh,” Wikipedia: https://en.wikipedia.org/wiki/Bill_Walsh (49ers head coach 1979–1988; Super Bowls XVI, XIX and XXIII). Pro Football Reference, “1971 NFL Leaders”: Carter led with 62.2% (138 of 222): https://www.pro-football-reference.com/years/1971/leaders.htm↩︎

  7. Course calculation from nflverse’s republication of FTN charting (load_ftn_charting(), field read_thrown) joined to play-by-play and participation, 2022–2025 regular seasons, pass attempts (sacks excluded). The nflverse data dictionary describes the codes as “0” = first read, “1” = second read and “2” = third read or later, but that does not match the released files: “0” appears on only about 2–3% of 2023–2025 attempts, most of them throwaways, while “1” covers more than half of all attempts with the quickest release. This chapter therefore reads “1” as the first read, “2” as a later read and “0” as no read, and labels the chart accordingly (https://nflreadr.nflverse.com/articles/dictionary_ftn_charting.html). The release files support that reading: “1” has about the same count in 2022 as in 2023–2025 (some 9,300–9,800 attempts a season), although the dictionary says first reads are uncoded in 2022; and from 2023 “0” is also the value on every run play (in 2022 those are blank), which makes it a fill value, not a read.↩︎

  8. nflverse play-by-play definitions: air_yards is the “distance in yards perpendicular to the line of scrimmage at where the targeted receiver either caught or didn’t catch the ball”; yards_after_catch is the distance “from the yard line where the receiver made the reception to where the play ended” (https://nflreadr.nflverse.com/articles/dictionary_pbp.html). Course calculation, 2025 regular season: mean air_yards over all pass attempts with a charted depth, and the sum of yards_after_catch divided by the sum of receiving_yards on completions.↩︎

  9. Course calculation from nflverse play-by-play, 2021–2025 regular seasons: mean epa of all runs and passes on first-and-10 that gained exactly four, five or six yards. Completed throws behind the line: mean yards_gained on completions with negative air_yards, same seasons.↩︎

  10. “Super Bowl XXIII,” Wikipedia: https://en.wikipedia.org/wiki/Super_Bowl_XXIII (Jan. 22, 1989, Joe Robbie Stadium, Miami; 3:10 left after the kickoff, a penalty putting the 49ers back at their 8; 92 yards in 11 plays; the 10-yard touchdown to Taylor with 34 seconds left; Walsh’s last game). The call “20 Halfback Curl, X-Up,” designed for Roger Craig, with Rice in motion “to clear out the back side”: The Philadelphia Inquirer, “Taylor makes amends with the winning catch” (Jan. 25, 2008): https://www.inquirer.com/philly/online_extras/20080125_TAYLOR_MAKES_AMENDS_WITH_THE_WINNING_CATCH.html ; the snap with 39 seconds left, with Walsh diagramming the play: FootballScoop, https://footballscoop.com/watch-legendary-bill-walsh-diagram-explain-play-won-super-bowl-23 . Taylor’s own account names Craig, Rice and himself as “the three options”: San Francisco 49ers, “75 for 75: The Drive”: https://www.49ers.com/news/75-for-75-the-drive-joe-montana-super-bowl-xxiii-cincinnati-bengals↩︎

  11. Course calculation from nflverse play-by-play, FTN charting and participation (Next Gen Stats time to throw), 2022 regular season, pass attempts by Brady and Mahomes (sacks excluded). “Left the pocket or the play broke down” counts throws charted by FTN as out of the pocket or as scramble-drill throws. Among the 29 passers with 300 or more 2022 attempts with a charted target depth, Brady had the lowest average time to throw (2.45 seconds); Mahomes averaged 2.89.↩︎