Watching Live: The Master Checklist and Anticipation

Twenty seconds between snaps, nine questions in the right order, the full catalogue of tells, and a prediction with a number on it.

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

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch, Rectangle, Circle, Patch
from matplotlib.lines import Line2D

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

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
LOOK = "#0f7a55"           # "look here" badges (a darker step of the aqua highlight)
NEW_TINT = "#dcefe6"       # rows that are new in this chapter on the card


def pct(x, nd=0):
    """0.613 -> '61%'."""
    return f"{100 * x:.{nd}f}%"


def blank(ax):
    ax.set_axis_off()
    ax.grid(False)


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


def badge(ax, x, y, n, color=LOOK, r=1.15, fs=7.5, z=15):
    """A numbered circle in plot coordinates (equal-aspect axes only)."""
    ax.add_patch(Circle((x, y), r, fc=color, ec="white", lw=0.8, zorder=z))
    ax.text(x, y, str(n), ha="center", va="center", fontsize=fs, color="white", fontweight="bold", zorder=z + 1)


def fbadge(fld, play, n, at, **kw):
    """A numbered badge at alignment coordinates (d, w)."""
    x, y = fld.xy(*play.to_abs(*at))
    badge(fld.ax, float(x), float(y), n, **kw)


def tag(fld, play, text, at, fs=7.0, ha="center", color=style.INK, bold=False, ec=style.BASELINE):
    """A boxed label at alignment coordinates (d, w), drawn above the players."""
    x, y = fld.xy(*play.to_abs(*at))
    fld.ax.text(x, y, text, ha=ha, va="center", fontsize=fs, color=color, zorder=14, linespacing=1.12,
                fontweight="bold" if bold else "normal",
                bbox=dict(boxstyle="round,pad=0.25", fc=style.SURFACE, ec=ec, lw=0.6))


def scorebug(fld, play, lines, at, fs=6.6, ha="left"):
    """A small dark scorebug box at (d, w)."""
    x, y = fld.xy(*play.to_abs(*at))
    fld.ax.text(x, y, "\n".join(lines), ha=ha, va="center", fontsize=fs, color="white", zorder=16,
                linespacing=1.25, fontweight="bold",
                bbox=dict(boxstyle="round,pad=0.35", fc="#2b2b2b", ec="none"))


# ------------------------------------------------------------------ print stand-ins for animations
def snapshot(play, t, ax, lateral, window, notes=(), trails=True):
    """Everyone where he is at time t, with faint trails of where he has been and the ball in flight."""
    pos = {k: play.position(k, t) for k in play.players}
    moved = [pl.moved(d=float(pos[k][0]), w=float(pos[k][1])) for k, pl in play.players.items()]
    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 n in notes:
        q.note(*n)
    fld = q.draw(ax=ax, lateral=lateral, window=window, title=False)
    no_numbers(fld.ax)
    ppy = fld.pts_per_yard()
    P = lambda d, w: fld.xy(*play.to_abs(d, w))  # noqa: E731
    t0 = play._compile()[1]
    if trails:
        for k, pl in play.players.items():
            if pl.pos in ("LT", "LG", "C", "RG", "RT"):
                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.4:
                continue
            xs, ys = P(pts[:, 0], pts[:, 1])
            col = style.OFFENSE if pl.side == "off" else style.DEFENSE
            ls = "-" if pl.side == "off" else (0, (3, 2))
            ax.plot(xs, ys, color=col, lw=1.0, alpha=0.5, ls=ls, 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.3, ls=(0, (2, 1.5)), zorder=9))
    if thrown:
        draw_marker(ax, float(bx), float(by), "ball", "", ppy)   # before the throw the QB has it: no icon on him
    elif t > 0:
        for who, tgt in play.reads:                               # the quarterback's eyes, until he throws
            (x0, y0), (x1, y1) = P(*play.position(who, t)), P(*play.position(tgt, t))
            ax.plot([float(x0), float(x1)], [float(y0), float(y1)], color=style.THIRD, lw=1.2, ls=(0, (4, 2)),
                    zorder=4)
    return fld


def strip(play, times, labels, lateral, window, notes_at=None, ncols=2, width_in=6.5):
    """The print version of an animation: one panel per moment, each titled with what to see."""
    n = len(times)
    nrows = int(np.ceil(n / ncols))
    pw = width_in / ncols
    h = nrows * (pw * (window[1] - window[0]) / abs(lateral[1] - lateral[0]) + 0.36)
    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(play, t, ax, lateral, window, notes=(notes_at or {}).get(i, ()))
        ax.set_title(lab, fontsize=8.2, color=style.INK, loc="left")
    fig.tight_layout(h_pad=0.5, w_pad=0.4)
    return fig


def animate_or_strip(play, times, labels, lateral, window, notes_at=None):
    """Video on the web, a titled frame strip in print."""
    if PDF:
        strip(play, times, labels, lateral, window, notes_at=notes_at)
        plt.show()
    else:
        show_animation(play, times=times)


def protect(p, linemen=("LT", "LG", "C", "RG", "RT")):
    """Five linemen set back into pass protection."""
    for k in linemen:
        p.block(k, pts=[(-1.2, 0.0)])


def engage(p, rushers, speed=2.0):
    """Rushers who run into the protection (enough to show a pass rush, no more)."""
    for k in rushers:
        p.rush(k, pts=[(-1.6, 0.0)], speed=speed)
Show the Python (data: nflverse play-by-play, participation and FTN charting, 2024–2025)
from gridiron.data import pbp, nfl

PCOLS = ["season_type", "game_id", "play_id", "posteam", "defteam", "play_type", "down", "ydstogo",
         "yardline_100", "pass", "half_seconds_remaining", "score_differential", "qtr", "fixed_drive", "time",
         "yrdln", "desc", "yards_gained", "posteam_score", "defteam_score", "air_yards", "yards_after_catch",
         "qb_dropback", "run_location", "pass_location"]


def load_season(season):
    """Runs and dropbacks with FTN charting (alignment, motion, tempo, rushers) and participation
    (personnel, box count, coverage label) attached."""
    d = pbp([season], columns=PCOLS)
    d = d[d.play_type.isin(["pass", "run"]) & d.down.notna()].copy()
    f = nfl().load_ftn_charting([season]).to_pandas()
    f = f.rename(columns={"nflverse_game_id": "game_id", "nflverse_play_id": "play_id"})
    f["play_id"] = f.play_id.astype(float)
    f = f[["game_id", "play_id", "starting_hash", "qb_location", "n_offense_backfield", "is_motion", "is_no_huddle",
           "is_play_action", "is_screen_pass", "n_pass_rushers", "n_blitzers", "read_thrown",
           "is_qb_out_of_pocket"]]
    p = nfl().load_participation([season]).to_pandas()
    p = p.rename(columns={"nflverse_game_id": "game_id"})
    p["play_id"] = p.play_id.astype(float)
    p = p[["game_id", "play_id", "offense_personnel", "defense_personnel", "defenders_in_box",
           "defense_coverage_type", "defense_man_zone_type", "time_to_throw", "was_pressure", "route"]]
    d["play_id"] = d.play_id.astype(float)
    return d.merge(f, on=["game_id", "play_id"], how="left").merge(p, on=["game_id", "play_id"], how="left")


def count_pos(s, keys):
    if not isinstance(s, str):
        return np.nan
    c = {k: int(v) for v, k in re.findall(r"(\d+) ([A-Z]+)", s)}
    return sum(c.get(k, 0) for k in keys)


def pers_code(s):
    """FTN-era personnel string ('1 C, 2 G, 1 QB, 1 RB, 2 T, 1 TE, 3 WR') -> '11'."""
    if not isinstance(s, str):
        return "na"
    c = {k: int(v) for v, k in re.findall(r"(\d+) ([A-Z]+)", s)}
    code = f"{c.get('RB', 0) + c.get('FB', 0)}{c.get('TE', 0)}"
    return code if code in ("10", "11", "12", "13", "21", "22", "01", "02") else "other"


def features(d):
    d = d.copy()
    dist = pd.cut(d.ydstogo, [0, 1, 2, 3, 6, 10, 99], labels=["1", "2", "3", "4-6", "7-10", "11+"]).astype(str)
    d["dd"] = d.down.astype(int).astype(str) + "_" + dist
    d["zone"] = pd.cut(d.yardline_100, [0, 5, 10, 20, 40, 60, 80, 100],
                       labels=["g5", "g10", "rz", "o40", "mid", "own40", "own20"]).astype(str)
    d["score"] = pd.cut(d.score_differential, [-99, -9, -1, 0, 8, 99],
                        labels=["d9+", "d1-8", "tie", "u1-8", "u9+"]).astype(str)
    d["late"] = np.where(d.half_seconds_remaining <= 120, "late", "no")
    d["q4score"] = np.where(d.qtr >= 4, "q4", "no") + d["score"]
    d["pers"] = d.offense_personnel.map(pers_code)
    backs = d.n_offense_backfield.fillna(-1).clip(upper=2).astype(int).astype(str)
    d["formation"] = d.qb_location.fillna("na").astype(str) + backs
    d["motion"] = d.is_motion.fillna(False).astype(bool).astype(str)
    d["tempo"] = d.is_no_huddle.fillna(False).astype(bool).astype(str)
    d["box"] = pd.cut(d.defenders_in_box.fillna(-1), [-2, 0, 5, 6, 7, 99],
                      labels=["na", "5-", "6", "7", "8+"]).astype(str)
    d["dbs"] = d.defense_personnel.map(lambda s: count_pos(s, ("CB", "FS", "SS", "S", "DB")))
    return d


# The checklist steps that can be measured in public data, in checklist order
STEPS = [("Situation", ["dd", "zone", "score", "late", "q4score"]),
         ("+ Personnel", ["pers"]),
         ("+ Formation", ["formation"]),
         ("+ Motion and tempo", ["motion", "tempo"]),
         ("+ Box count", ["box"])]


def design(d, cats, levels=None):
    if levels is None:
        levels = {c: sorted(d[c].unique()) for c in cats}
    cols = [np.ones(len(d))]
    for c in cats:
        for lv in levels[c][1:]:
            cols.append((d[c].values == lv).astype(float))
    return np.column_stack(cols), levels


def fit(X, y, lam=1.0, iters=30):
    """Ridge-penalised logistic regression by Newton's method (numpy only)."""
    w = np.zeros(X.shape[1])
    for _ in range(iters):
        p = 1 / (1 + np.exp(-X @ w))
        H = X.T @ (X * (p * (1 - p))[:, None]) + lam * np.eye(X.shape[1])
        w -= np.linalg.solve(H, X.T @ (p - y) + lam * w)
    return w


def nested(train):
    models, cats = [], []
    for name, cs in STEPS:
        cats = cats + cs
        X, lv = design(train, cats)
        models.append((name, list(cats), lv, fit(X, train["pass"].values.astype(float))))
    return models


def predict(model, d):
    _, cats, lv, w = model
    X, _ = design(d, cats, lv)
    return 1 / (1 + np.exp(-X @ w))


S24 = features(load_season(2024))
S25 = features(load_season(2025))
TR = S24[S24.season_type == "REG"]
TE = S25[S25.season_type == "REG"]

# What each step is worth: learn on 2024, score on 2025
MODELS = nested(TR)
WORTH = [dict(step="Always guess pass", acc=float(TE["pass"].mean()), hi=np.nan, hi_acc=np.nan)]
for m in MODELS:
    p_ = predict(m, TE)
    y_ = TE["pass"].values
    right = (p_ >= 0.5) == y_
    hi = np.maximum(p_, 1 - p_) >= 0.8
    WORTH.append(dict(step=m[0], acc=float(right.mean()), hi=float(hi.mean()), hi_acc=float(right[hi].mean())))
W = {w_["step"]: w_ for w_ in WORTH}

# Confidence bands after the first three steps (situation, personnel, formation)
pF = predict(MODELS[2], TE)
cF = np.maximum(pF, 1 - pF)
rF = (pF >= 0.5) == TE["pass"].values
BANDS = []
for lo, hi_ in [(0.5, 0.6), (0.6, 0.7), (0.7, 0.8), (0.8, 0.9), (0.9, 1.01)]:
    m_ = (cF >= lo) & (cF < hi_)
    BANDS.append(dict(lo=lo, hi=min(hi_, 1.0), share=float(m_.mean()), acc=float(rF[m_].mean()),
                      conf=float(cF[m_].mean())))


def prate(mask):
    return float(TE.loc[mask, "pass"].mean())


RATE = dict(
    all=prate(TE.down > 0),
    first10=prate((TE.down == 1) & (TE.ydstogo == 10)),
    third7=prate((TE.down == 3) & TE.ydstogo.between(7, 10)),
    third46gun11=prate((TE.down == 3) & TE.ydstogo.between(4, 6) & (TE.pers == "11") & (TE.qb_location == "S")),
    third3gun=prate((TE.down == 3) & (TE.ydstogo == 3) & (TE.qb_location == "S")),
    third12uc=prate((TE.down == 3) & TE.ydstogo.between(1, 2) & (TE.qb_location == "U")),
    third12gun=prate((TE.down == 3) & TE.ydstogo.between(1, 2) & (TE.qb_location == "S")),
    second12=prate((TE.down == 2) & TE.ydstogo.between(1, 2)),
    second12gun11=prate((TE.down == 2) & TE.ydstogo.between(1, 2) & (TE.pers == "11") & (TE.qb_location == "S")),
    second12gun11nh=prate((TE.down == 2) & TE.ydstogo.between(1, 2) & (TE.pers == "11")
                          & (TE.qb_location == "S") & (TE.tempo == "True")),
    first21uc=prate((TE.down == 1) & (TE.ydstogo == 10) & (TE.pers == "21") & (TE.qb_location == "U")),
    first21uc8=prate((TE.down == 1) & (TE.ydstogo == 10) & (TE.pers == "21") & (TE.qb_location == "U")
                     & (TE["box"] == "8+")),
    first12uc=prate((TE.down == 1) & (TE.ydstogo == 10) & (TE.pers == "12") & (TE.qb_location == "U")),
    empty=prate(TE["formation"] == "S0"),
    gun=prate(TE.qb_location == "S"), pistol=prate(TE.qb_location == "P"), uc=prate(TE.qb_location == "U"),
    nohuddle=prate(TE.tempo == "True"),
)
LOOK6 = ((TE.down == 2) & TE.ydstogo.between(1, 2) & (TE.pers == "11") & (TE.qb_location == "S")
         & (TE.n_offense_backfield == 1) & (TE.motion == "True") & (TE["box"] == "6"))
RATE["look6"] = prate(LOOK6)
N_LOOK6 = int(LOOK6.sum())
N_SECOND12NH = int(((TE.down == 2) & TE.ydstogo.between(1, 2) & (TE.pers == "11") & (TE.qb_location == "S")
                    & (TE.tempo == "True")).sum())
N_FIRST21UC8 = int(((TE.down == 1) & (TE.ydstogo == 10) & (TE.pers == "21") & (TE.qb_location == "U")
                    & (TE["box"] == "8+")).sum())

# Rushers and coverage on dropbacks (2025 regular season)
DBK = S25[(S25.season_type == "REG") & (S25.qb_dropback == 1) & (S25.n_pass_rushers > 0)].copy()
DBK["five"] = (DBK.n_pass_rushers >= 5).astype(float)
COV = DBK[DBK.defense_man_zone_type.isin(["MAN_COVERAGE", "ZONE_COVERAGE"])].copy()
COV["man"] = (COV.defense_man_zone_type == "MAN_COVERAGE").astype(float)


def rush5(mask):
    return float(DBK.loc[mask, "five"].mean())


RUSH = dict(all=rush5(DBK.down > 0), third46=rush5((DBK.down == 3) & DBK.ydstogo.between(4, 6)),
            third7=rush5((DBK.down == 3) & DBK.ydstogo.between(7, 10)))
MAN_ALL = float(COV["man"].mean())
MAN_RZ = float(COV.loc[COV.yardline_100 <= 20, "man"].mean())
MAN_OPEN = float(COV.loc[COV.yardline_100 > 20, "man"].mean())


def uc_habits(T):
    """How often a team lines up under center, throws from there, and makes those throws play-action."""
    U = T[T.qb_location == "U"]
    return dict(share=float((T.qb_location == "U").mean()), pass_=float(U["pass"].mean()),
                pa=float(U.loc[U["pass"] == 1, "is_play_action"].mean()))


UC_LG, UC_LA = uc_habits(TE), uc_habits(TE[TE.posteam == "LA"])
M12 = (TE.down == 1) & (TE.pers == "12") & (TE.qb_location == "U")
RATE["first12uc_sea"] = prate(M12 & (TE.posteam == "SEA"))
RATE["first12uc_all"] = prate(M12)
N_12UC_SEA = int((M12 & (TE.posteam == "SEA")).sum())

# Field or boundary? FTN's hash is from the offense's view (screens go to the wide side, away from it)
HSH = TE[TE.starting_hash.isin(["L", "R"])]
TO_FIELD = (((HSH.starting_hash == "L") & (HSH.run_location.eq("right") | HSH.pass_location.eq("right")))
            | ((HSH.starting_hash == "R") & (HSH.run_location.eq("left") | HSH.pass_location.eq("left"))))
WIDE = HSH.run_location.isin(["left", "right"]) | HSH.pass_location.isin(["left", "right"])
SCR = WIDE & (HSH.is_screen_pass == True)  # noqa: E712
RUNW = WIDE & (HSH.play_type == "run")
FIELD = dict(screen=float(TO_FIELD[SCR].mean()), run=float(TO_FIELD[RUNW].mean()))


def brier(c, q):
    """Expected Brier score per call when you say c and the call comes true at rate q."""
    return q * (1 - c) ** 2 + (1 - q) * c ** 2


def buckets(d):
    out = pd.DataFrame(index=d.index)
    dist = pd.cut(d.ydstogo, [0, 3, 6, 99], labels=["1-3", "4-6", "7+"]).astype(str)
    out["b_dd"] = d.down.astype(int).clip(upper=3).astype(str) + "_" + dist
    out["b_rz"] = np.where(d.yardline_100 <= 20, "rz", "open")
    out["b_db"] = d.dbs.fillna(5).clip(4, 6).astype(int).astype(str)
    return out


for t_ in (DBK, COV):
    t_[["b_dd", "b_rz", "b_db"]] = buckets(t_)


def lookup(table, col, row, nmin=60):
    """Base rate for this down-distance, field zone and number of DBs, backing off to coarser cells."""
    for keys in (("b_dd", "b_rz", "b_db"), ("b_dd", "b_rz"), ("b_dd",)):
        m_ = np.ones(len(table), bool)
        for k in keys:
            m_ &= table[k].values == row[k]
        if m_.sum() >= nmin:
            return float(table.loc[m_, col].mean())
    return float(table[col].mean())


# ------------------------------------------------------------------ the film-room drive
DRV = S25[(S25.game_id == "2025_21_LA_SEA") & (S25.posteam == "SEA") & (S25.fixed_drive == 15)]
DRV = DRV.sort_values("play_id").reset_index(drop=True)
TRACE = nested(pd.concat([TR, TE]))           # trained on the 2024 and 2025 regular seasons
for i_, m in enumerate(TRACE):
    DRV[f"p{i_}"] = predict(m, DRV)
DRV["p"] = DRV[f"p{len(TRACE) - 1}"]


def conf_of(p):
    return int(np.clip(5 * round(100 * max(p, 1 - p) / 5), 50, 90))


DRV["call"] = np.where(DRV.p >= 0.5, "Pass", "Run")
DRV["conf"] = DRV.p.map(conf_of)
bk = buckets(DRV)
DRV["p_man"] = [lookup(COV, "man", bk.loc[i_]) for i_ in DRV.index]
DRV["p_five"] = [lookup(DBK, "five", bk.loc[i_]) for i_ in DRV.index]
DRV["cov_call"] = np.where(DRV.p_man >= 0.5, "man", "zone")
DRV["cov_conf"] = DRV.p_man.map(conf_of)
DRV["rush_call"] = np.where(DRV.p_five >= 0.5, "5+", "4")
DRV["rush_conf"] = DRV.p_five.map(conf_of)
DRV["is_pass"] = DRV["pass"] == 1
DRV["hit"] = (DRV.call == "Pass") == DRV.is_pass
DRV["cov_hit"] = np.where(DRV.is_pass, (DRV.cov_call == "man") == (DRV.defense_man_zone_type == "MAN_COVERAGE"), np.nan)
DRV["rush_hit"] = np.where(DRV.is_pass, (DRV.rush_call == "5+") == (DRV.n_pass_rushers >= 5), np.nan)
RESULT = {2550: "Holani run, +3", 2572: "Walker run, −2", 2594: "Kupp catch, +12 (1st)",
          2619: "Walker run, +11 (1st)", 2641: "Holani catch, +9", 2666: "Smith-Njigba screen, +12 (1st)",
          2691: "Play-action, Barner +7", 2716: "Incomplete to Bobo", 2739: "Kupp, 13-yard TD"}
DRV["result"] = DRV.play_id.map(lambda x: RESULT[int(x)])
D = DRV.set_index(DRV.index + 1)           # D.loc[3] is the third snap of the drive
N_HIT = int(DRV.hit.sum())
N_L2 = int(np.nansum(DRV.cov_hit) + np.nansum(DRV.rush_hit))
N_L2_ALL = int(2 * DRV.is_pass.sum())
N_COV = int(np.nansum(DRV.cov_hit))
N_RUSH = int(np.nansum(DRV.rush_hit))
N_PASS = int(DRV.is_pass.sum())


_a8 = int(D.loc[8, "air_yards"])
AIR8 = f"{abs(_a8)} yard{'s' if abs(_a8) != 1 else ''} {'behind' if _a8 < 0 else 'past'} the line"
Q90 = BANDS[4]["acc"]
BRIER = dict(c90=brier(0.90, Q90), honest=brier(Q90, Q90), c99=brier(0.99, Q90))


def snap_conf(i):
    return f"{D.loc[i, 'call'].lower()}, {D.loc[i, 'conf']}%"

The NFC Championship Game, January 25, 2026. Third quarter, 8:23 to play, and the Rams have just pulled within four points. Seattle faces third-and-9 at its own 36, and on a typical broadcast the replay of the run that just lost two yards would still be rolling as the Seahawks come back to the line: one running back, one tight end, three receivers, quarterback in the shotgun. The Rams answer with six defensive backs and only five men near the ball. You have about twenty seconds. Most viewers spend them waiting for the snap. A coach in the booth spends them asking a set of questions in the same order he asks them before every snap: What does the situation demand? Who’s on the field? How are they lined up? What does the defense do when someone moves? How many are in the box? How many are deep? Where is the help? Is anyone coming? And then he says out loud what he thinks will happen, and how sure he is. This chapter puts the whole course into that routine. By the end of it you’ll run the same questions in the same order, and you’ll keep score.

You’ll be able to…

NoteYou’ll need

This is a Level 5 chapter: it assembles the course rather than adding new schemes. One line on each piece it leans on hardest:

Twenty seconds, a hundred clues

Start with the problem the checklist solves: there’s far more to see before a snap than anyone can see, and very little time. The play clock is 40 seconds, starting the moment the previous play is whistled dead (25 seconds after some stoppages, such as a timeout or a change of possession).1 On a broadcast the first part of that is usually spent on replays of the play that just ended. The offense huddles while its play-caller talks to the quarterback through the helmet radio, which goes silent with 15 seconds left on the play clock.2 Then the players walk to the line, set, maybe move someone in motion, and the ball is snapped. A team that huddles spends most of what is left of the play clock at the line; a team in tempo can snap within a few seconds of setting.

So your budget is roughly twenty seconds, from the moment the scorebug updates to the snap, and the camera won’t show you all of it. Within those twenty seconds, information doesn’t arrive all at once. It arrives in a fixed order, because the game produces it in a fixed order:

Show the Python
fig, ax = plt.subplots(figsize=(6.5, 3.55))
blank(ax)
ax.set_xlim(41.6, -1.4)
ax.set_ylim(-0.4, 10.6)
YA = 6.3
ax.plot([40, 0], [YA, YA], color=style.INK_2, lw=1.0, solid_capstyle="butt")
for s in range(40, -1, -5):
    ax.plot([s, s], [YA - 0.17, YA + 0.17], color=style.INK_2, lw=0.9)
    ax.text(s, YA + 0.32, f":{s:02d}", ha="center", va="bottom", fontsize=6.6, color=style.INK_2)
PHASES = [(40, 30, "play over; TV shows\nreplays; players regroup", "#ecebe4"),
          (30, 21, "huddle: the call comes\nin by helmet radio", "#dfeaf7"),
          (21, 15.4, "break, line\nup, set", "#dfeaf7"),
          (14.6, 3, "motion and cadence;\nthe defense shows its look", "#fbe3d7"),
          (3, 0, "snap", "#f6e5b0")]
for a, b, txt, c in PHASES:
    ax.add_patch(Rectangle((b, 7.35), a - b, 1.75, fc=c, ec="white", lw=1.6, zorder=2))
    ax.text((a + b) / 2, 8.22, txt, ha="center", va="center", fontsize=6.3, color=style.INK, linespacing=1.1,
            zorder=3)
ax.text(40, 9.75, "A huddling offense, typical timing", ha="left", va="center",
        fontsize=7.6, fontweight="bold", color=style.INK)
ax.text(0, YA - 0.42, "seconds left on the play clock", ha="right", va="center", fontsize=6.3, color=style.INK_2,
        style="italic")
ax.plot([15, 15], [3.3, 9.25], color=style.DEFENSE_DARK, lw=1.0, ls=(0, (3, 2)), zorder=4)
ax.text(14.6, 9.75, "radio off at :15", ha="left", va="center", fontsize=6.6, color=style.DEFENSE_DARK)
STEPS_AT = [(1, 35.0, "situation"), (2, 25.5, "personnel"), (3, 18.0, "formation"), (4, 12.6, "motion"),
            (5, 10.1, "box"), (6, 7.6, "shell"), (7, 5.1, "leverage"), (8, 2.8, "pressure"), (9, 0.5, "call it")]
YC = 4.4
for n, s, lab in STEPS_AT:
    ax.scatter([s], [YC], s=150, color=LOOK, edgecolor="white", linewidth=0.8, zorder=5)
    ax.text(s, YC, str(n), ha="center", va="center", fontsize=7, color="white", fontweight="bold", zorder=6)
    above = n % 2 == 0
    ax.text(s, YC + (0.62 if above else -0.62), lab, ha="center", va="bottom" if above else "top",
            fontsize=6.4, color=style.INK)
ax.text(40, 5.55, "The checklist, step by step", ha="left", va="center", fontsize=7.6, fontweight="bold",
        color=style.INK)
ax.add_patch(Rectangle((27, 0.55), 13, 1.25, fc="#ecebe4", ec="white", lw=1.6))
ax.text(39.6, 1.18, "against tempo: the snap\ncan come at :25 or sooner", ha="left", va="center", fontsize=6.2,
        color=style.INK, linespacing=1.1)
for n, s in ((1, 25.5), (3, 23.0), (6, 20.5), (9, 18.0)):
    ax.scatter([s], [1.18], s=120, color=LOOK, edgecolor="white", linewidth=0.8, zorder=5)
    ax.text(s, 1.18, str(n), ha="center", va="center", fontsize=6.6, color="white", fontweight="bold", zorder=6)
ax.text(16.6, 1.18, "short checklist:\nsituation, formation, shell, call", ha="left", va="center", fontsize=6.2,
        color=style.INK_2, linespacing=1.1)
ax.text(40, 2.45, "No-huddle", ha="left", va="center", fontsize=7.6, fontweight="bold", color=style.INK)
plt.show()
Figure 74.1: Twenty seconds, in order. Top: what a typical huddling offense does with the 40-second play clock (timings vary by team and snap; only the radio cut-off at :15 is a rule). Middle: the nine pre-snap steps of the master checklist, placed where each answer usually becomes visible: the situation during the replay, the personnel as players run on, the formation when the offense sets, the motion and the defense’s reply during the cadence, and the box, shell, leverage and pressure in the last few seconds, when the defense finally shows its hand. Bottom: against tempo everything is compressed, so you keep only steps 1, 3, 6 and 9.

That ordering is the first reason the checklist has the shape it has: each step is asked when its answer is visible. There is no point hunting for the safeties while the offense is still in the huddle; they haven’t settled. There is every point in reading the scorebug during the replay, because the situation is already fixed and nothing else is.

The second reason is less obvious and more important: each answer changes what the next clue means. A safety walking down to eight yards on third-and-9 is a defense guarding the sticks; the same move on first-and-10 against two tight ends is an eighth man for the run. A receiver tightening his split means one thing from a team that runs on 70% of its snaps in that look and another from a team that throws on 70%. Clues aren’t independent bits of evidence to be added up. They are answers to questions, and the questions come in an order. Read the box before you know the personnel, and you’ll miscount it. Read the shell before you know the down, and you won’t know what you’re looking at.

ImportantWhy it exists: a checklist, of all things

A checklist sounds like the opposite of expertise. Surely the expert just sees it? But the professions that have to perform under time pressure without missing anything (pilots running a pre-flight check, surgical teams before an incision) settled long ago on short, ordered checklists, and not because their people were beginners. A checklist offloads the remembering so that attention is free for the judging.3 Yours is the kind Gawande calls do-confirm: you run it from memory, then glance at the card to make sure you skipped nothing. A quarterback’s pre-snap routine (count the safeties, find the Mike, know where the hot throw goes) is a checklist too. Yours fights the same enemy: the eye goes to the most interesting thing on the screen (usually the quarterback) and stays there, while the clue that decided the play happens forty yards away.

What it gives up: a fixed routine is slower than a glance, and on the snaps where the offense hurries you’ll have to cut it short. That’s why the card has a short version.

The master checklist

Here is the card you’ve been building since Part 2, finished. It is the course’s master pre-snap checklist: the complete pre-snap routine, in the same order before every snap, followed by what to check at the snap and between snaps.

Show the Python
items = [
    ("BEFORE THE SNAP  ·  about 20 seconds", None, None, None, False),
    ("1", "Situation: down, distance, field zone, hash (which side is the field), clock, score, timeouts. "
          "Say the base rate: 1st-and-10 about " + pct(RATE["first10"]) + ", 3rd-and-7 to 10 about "
          + pct(RATE["third7"]) + ", empty about " + pct(RATE["empty"]) + " pass. Two-minute or four-minute?",
     "3 s", "Broadcast · Clock · Tendencies", False),
    ("2", "Personnel: count backs and tight ends, and notice *which* receivers. Did the defense match (base, "
          "nickel, dime), or did tempo freeze it?", "2 s", "Personnel · Tempo", False),
    ("3", "Formation and strength (to the tight end, the receivers or the field?): under center, gun or "
          "pistol; the back's depth and offset; splits; the tight end's stance; heavy or light hands; a "
          "guard cheating back", "3 s", "Formations · tells", True),
    ("4", "Motion response: does a defender travel (man), bump (zone) or spin a safety? A lean, not a read. "
          "Still moving at the snap?", "2 s", "Motion · Disguise", False),
    ("5", "Box: blockers against defenders near the line. Light (6 or fewer) or loaded (8 or more)?", "2 s",
     "Box math", False),
    ("6", "Shell: one-high or two-high; each safety's depth and width; who is creeping late?", "2 s",
     "Coverage · Disguise", False),
    ("7", "Leverage: corners press or off, inside or outside; linebacker depth; where is the help?", "2 s",
     "Coverage · tells", True),
    ("8", "Pressure tells: mugs, a safety or nickel walked up, a lineman on his heels; the center or QB "
          "pointing, a slide call. Count the deep players; where is the hot?", "2 s", "Pressure · tells", True),
    ("9", "Call it: run or pass with a stated confidence (50–90%), plus one second-layer call "
          "(coverage, rush, target, or the run's direction)", "2 s", "this chapter", True),
    ("AT THE SNAP", None, None, None, False),
    ("10", "Linemen's first step (run or pass; together = zone, a puller = gap); did a safety rotate; how "
           "many rushed?", "", "Run game · Pressure", False),
    ("ONE SECOND AFTER", None, None, None, False),
    ("11", "Middle of the field closed or open; corners squat, bail or trail; where did the ball go?", "",
     "Coverage · Adjustments", False),
    ("BETWEEN SNAPS", None, None, None, False),
    ("12", "Log it: call, confidence, the clue that decided it, the result. Five seconds, every snap", "",
     "Broadcast · this chapter", True),
    ("13", "At each stoppage: update today's tendencies (this personnel, this down); tempo and clock "
           "status; what did they just adjust? On fourth down, call go, kick or punt", "",
     "Adjustments · Clock", True),
]
rows = []
for it in items:
    if it[1] is None:
        rows.append(("head", it[0], 1))
    else:
        lines_ = textwrap.wrap(it[1].replace("*", ""), 50)
        rows.append(("item", (it[0], lines_, it[2], it[3], it[4]), len(lines_)))
n_lines = sum(h for _, _, h in rows) + 0.18 * sum(k == "item" for k, _, _ in rows) + 4.0
fig, ax = plt.subplots(figsize=(6.5, 0.235 * n_lines))
ax.set_xlim(0, 100)
ax.set_ylim(0, n_lines)
ax.axis("off")
ax.add_patch(FancyBboxPatch((0.8, 0.3), 98.4, n_lines - 0.6, boxstyle="round,pad=0.2,rounding_size=0.8",
                            fc=style.SURFACE, ec=style.BASELINE, lw=1.2))
ax.text(3, n_lines - 1.3, "The master checklist", fontsize=11.5, fontweight="bold", color=style.INK, va="center")
ax.text(97, n_lines - 1.3, "new in the capstone", fontsize=8, color=style.INK, ha="right", va="center",
        bbox=dict(boxstyle="round,pad=0.3", fc=NEW_TINT, ec="none"))
y = n_lines - 2.9
for kind, payload, h in rows:
    if kind == "head":
        ax.text(3, y, payload, fontsize=8, fontweight="bold", color=style.INK_2, va="center")
        y -= 1.05
        continue
    num, lines_, secs, where, new = payload
    top, bottom = y + 0.47, y - (h - 1) - 0.47
    if new:
        ax.add_patch(FancyBboxPatch((2.2, bottom), 95.6, top - bottom, boxstyle="round,pad=0.05,rounding_size=0.4",
                                    fc=NEW_TINT, ec="none"))
    ax.text(5.0, y, num, fontsize=8.5, fontweight="bold", color=LOOK, va="center", ha="center")
    ax.text(8.2, y, secs, fontsize=6.6, color=style.INK_2, va="center", ha="left")
    for j, ln in enumerate(lines_):
        ax.text(13.0, y - j, ln, fontsize=7.8, color=style.INK, va="center")
    ax.text(97, y, where, fontsize=6.6, color=style.INK_2, va="center", ha="right")
    y -= h + 0.18
plt.show()
Figure 74.2: The master checklist: the cards from Parts 2, 4, 6 and 7, completed and reordered for speed. Nine pre-snap steps take about twenty seconds in all (the seconds column is a target, not a rule). The shaded lines are what this chapter adds: the full tells catalogue folded into steps 3, 7 and 8, the prediction with a stated confidence as an explicit step, and the between-snaps habits that keep your read of this team, today, up to date. The right-hand column names the part of the course where each line was taught.

Against the last draft, at the end of Zone Blitzes and Simulated Pressure, three things changed. The protection question has been folded into the pressure step, so there are nine pre-snap steps, not ten. Each tell sits in the step where you’d see it (alignment tells in step 3, depth and leverage in step 7, pressure in step 8). And the prediction is a step of its own, with a number: “pass, 80%” before the snap, not “I had a feeling” after it. The rest of this section walks the nine steps in order: what you’re looking at, what it tells you, and why it sits where it does.

Step 1: situation, the base rate

Down, distance, field zone, clock, score, timeouts. You learned to read the scorebug in five seconds in How to Watch a Broadcast, and the situational chapters (Part 10, from Third Down to The Clock) taught you what each situation does to the play-caller. The output of step 1 is a number: the base rate, how often offenses throw in this situation before anyone has lined up. That is what xpass measures, and it’s the anchor every later step moves.

Add one more thing a coach reads first: where the ball is spotted. On a hash, one side of the formation has far more room (the field side) than the other (the boundary side). Defenses set their strength, rotations and blitzes by it, and offenses put their formations and perimeter plays into the room: in 2025, screens thrown from a hash went to the wide side 63% of the time.4

You don’t need a model in your head, just a few landmarks from the 2025 regular season:5

Situation Pass rate, 2025 What it means for your call
All snaps 60% “Always pass” is the floor any reader has to beat
1st-and-10 50% A coin flip until the formation speaks
2nd-and-1 or 2 31% Mostly runs, but this is where shots happen
3rd-and-1 or 2, under center 19% Run, and the formation says so
3rd-and-1 or 2, shotgun 52% A genuine coin flip
3rd-and-7 to 10 94% Pass; spend your seconds on the second layer
No-huddle, any down 70% Mostly offenses that are behind; under one in five comes in a two-minute drill

Two things to say alongside the number. First, is this a clock situation? In the two-minute drill and the four-minute offense the clock calls half the plays, and The Clock gave you a separate set of questions for them (in bounds or out, timeout or spike, kneel or not). Second, does this team break the base rate? That is PROE. Before kickoff, copy three numbers per team from the one-page scouting report in Tendencies onto your sheet: its PROE, its pass rate from under center, and its play-action rate. A team with a high PROE moves your 50% first down toward 55 or 60 before you’ve seen a thing.

Why first: it’s the only clue complete during the replay, and the biggest single clue there is.

Step 2: personnel, and the match

Count the backs and tight ends as the offense runs on, and watch who runs off. The two digits (11, 12, 21, 13) were the first thing Personnel Groupings taught, because personnel is chosen before the formation: the play-caller sends in a group, and the group limits what the formation can be. Then notice which players came on, not only how many. Defenses substitute for players as well as for numbers: a star receiver on the field can draw a nickel even when the digits say two tight ends (you’ll see exactly that on the drive below). A defense in nickel against 12 personnel has a defensive back where a linebacker usually stands, and the offense will often run at him; a defense in base against 11 personnel has a linebacker who may have to cover a slot receiver, and the offense will often throw at him. This is also where you notice tempo: an offense that hurries to the line without substituting after a big gain, or right after the defense subbed into a run-stopping group, is usually hunting a mismatch the substitution freeze has locked in.

Why second: personnel is visible before the formation is, and it’s the context for what the formation shows. On its own it adds surprisingly little to a run-or-pass call; its value is in what it sets up.

Step 3: formation, strength and the offensive tells

This is the richest step. Where is the quarterback: under center, in the shotgun, in the pistol? Where is the back, how deep, offset to which side? Where is the tight end, on the line or off it, in a three-point stance or standing up? How wide are the receivers? Which side is the formation strength?

Strength matters because both sides organise around it. The defense sets its front and usually its safety rotation to the strong side, and the offense’s runs and its best route combinations usually go there too. But “strong” is a dialect, not a fact: a defense may declare it to the tight end, to the side with more receivers, or to the field, and its choice decides which way the front and the rotation set (Formation Rules and Vocabulary showed all three). When the tight end is on one side and three receivers are on the other, watch which way the linebackers shift; that tells you which dialect this defense speaks.

The vocabulary came from Formation Rules and Vocabulary, the families of plays each look goes with from the run-game and passing-game parts, and the tells catalogue below collects every alignment clue in one place.

Why third: the offense has to show its formation before the defense commits to its final picture, and the defense’s last-second picture is a response to it. Read the formation first and you’ll understand what the defense does next.

Step 4: motion response

When a player moves before the snap, watch the defense, not the man in motion. A defender who travels across the formation with him suggests man coverage; defenders who bump a spot over suggest zone; a safety who spins down turns two-high into one-high. That’s the man/zone indicator from Motion and Shifts as Weapons. Then ask the offensive question: is he still moving when the ball is snapped (motion at the snap)? A jet motion at full speed is a run threat, or a decoy for one, to that side.

You will get to use this step on most snaps. PFF’s charting had a shift or motion on 63.9% of 2025 snaps, the highest in its series, and every team but the Giants used one on at least half its plays.6

Treat the answer as a lean, not a read. Defenses know this indicator as well as you do: a man defense can bump over and swap assignments (an exchange), and a pattern-matching zone can send a defender across with the motion, which is how Motion and Shifts showed the indicator lying. File the motion response next to leverage and the shell; when all three agree, believe it.

Why fourth: motion only happens after the offense sets, and its whole point is to make the defense answer a question before the snap. The answer is a clue for steps 5 to 7.

Step 5: the box

Count the defenders in the box and compare them with the blockers (box count). Six blockers against six defenders is a fair fight for a run; six against eight means two defenders will be unblocked, which is why an offense that sees eight usually throws or checks to a run away from the extra man (Run Fits and Box Math). A light box (six or fewer) against a shotgun offense is the classic invitation for a run or an RPO.

Why fifth: the box isn’t final until motion has settled it. Count too early and you’ll count a safety who is about to leave, or miss one who is about to arrive.

Step 6: the shell

One-high or two-high? Then the details: each safety’s depth (13 yards is deep, 8 is down), his width (on the hash or inside it, or out wide), and any creep in the last seconds of the play clock. This was the spine of the coverage chapters, and Disguise, Rotation, and Split-Field Coverage was about why the shell you see is often not the coverage you get. The numbers show the gap. After the snap, NFL Next Gen Stats measured two-high coverage on 42.0% of dropbacks in 2025, the highest since it began tracking in 2018.7 Before the snap, defenses show two-high far more often than that: preparing for Seattle’s defense before Super Bowl LX, the Patriots’ offensive coordinator, Josh McDaniels, said 65 of 70 snaps would “look the same before the ball’s snapped”8 (Fangio’s Two-High Revolution). The distance between the shell shown and the coverage played is the disguise. So two-high before the snap is not a tell by itself; the tell is which of the two safeties is shallower, narrower or moving.

Why sixth: safeties are the last defenders to settle and the most likely to be lying, so you read them as late as you can.

Step 7: leverage and depth

Look at the corners: pressed at the line or off at seven yards; on the receiver’s inside shoulder or his outside. Leverage tells you what the defender is protecting and so where his help is: a corner playing outside leverage is giving up the inside because someone (a safety in the middle, a linebacker underneath) is there to take it. Then the linebackers’ depth: at four and a half to five and a half yards they are playing the run and the short middle; creeping to three, they may be coming; backing out to eight before the snap on a passing down, they are getting a head start into zone drops.

Why seventh: leverage is the last thing a defense adjusts, and it only makes sense after you know the shell. An inside-leverage corner with two deep safeties means something different from the same corner with one.

Step 8: pressure tells

How many defenders are at or near the line, and could any of them drop? Linebackers mugging the A gaps, a safety or nickel back walked up beside the end, a defensive lineman sitting back on his heels: these are the pressure look tells from the pressure chapters. Then the count that caps everything: how many players are deep? Two deep safeties and two off corners leave seven to rush and cover underneath, and a defense almost never rushes six with only one underneath. Six-man pressure is real, but it comes as Cover 0, man-to-man with nobody deep, and that needs the safeties to walk down first; if they’re still at 12 yards at the snap, six isn’t coming.

Finish with the offense’s side, which is the easiest pressure tell to see on a broadcast: the offense reacting. The center pointing and calling out the middle linebacker, the quarterback stepping up to reset the protection or turning to the sideline, a back sliding across to the other side of the quarterback: each says the offense sees a pressure and has changed who blocks whom. Then ask which way the line will slide and where the hot route goes.

Why eighth: pressure looks are deliberately last-second. Defenses walk up late so the offense has as little time as possible to change its protection, and you get the same late look the quarterback does.

Step 9: call it, with a number

Say it out loud before the snap: run or pass, and how sure you are. Then, if you have a second left, one more layer: the coverage family, how many will rush, or where the ball should go. This is the step this chapter adds, and it gets its own section below. The rule is simple: the call is made before the snap, or it doesn’t count.

What each step is worth

How much does each step actually help? For the run-or-pass call, we can measure it. Charting services record, for every NFL snap, much of what steps 1 to 5 ask about: the situation, the personnel, the quarterback’s alignment and the number of backs, whether there was motion, whether the offense huddled, and how many defenders were in the box. (Steps 6 to 8 aren’t in public data: nobody charts pre-snap safety depth or corner leverage for every snap.) So we can do what a reader would do: learn from one season what each combination of clues usually meant, then call every snap of the next season, adding one step at a time.

Show the Python
fig, (a1, a2) = plt.subplots(1, 2, figsize=(6.5, 2.75), sharey=True, gridspec_kw=dict(width_ratios=[1.15, 1]))
names = [w_["step"] for w_ in WORTH]
ys = np.arange(len(names))[::-1]
cols = [style.MUTED] + [style.OFFENSE] * (len(names) - 1)
cols[3] = style.THIRD                          # the formation step: the big jump
accs = [w_["acc"] for w_ in WORTH]
a1.barh(ys, accs, color=cols, height=0.6, zorder=2)
for y_, v in zip(ys, accs):
    a1.text(v + 0.004, y_, pct(v, 1), va="center", ha="left", fontsize=7.4, color=style.INK)
a1.set_xlim(0.55, 0.81)
a1.set_yticks(ys, names, fontsize=7.6)
a1.xaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0%}"))
a1.set_xlabel("run/pass calls right, 2025", fontsize=7.6)
a1.set_title("Accuracy", fontsize=8.6)
a1.grid(axis="y", visible=False)
his = [0 if np.isnan(w_["hi"]) else w_["hi"] for w_ in WORTH]
a2.barh(ys, his, color=cols, height=0.6, zorder=2)
for y_, w_ in zip(ys, WORTH):
    if np.isnan(w_["hi"]):
        a2.text(0.01, y_, "never sure", va="center", ha="left", fontsize=7.0, color=style.INK_2, style="italic")
    else:
        a2.text(w_["hi"] + 0.008, y_, f"{pct(w_['hi'])}  ({pct(w_['hi_acc'])} right)", va="center", ha="left",
                fontsize=7.0, color=style.INK)
a2.set_xlim(0, 0.72)
a2.xaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0%}"))
a2.set_xlabel("share of snaps called at 80%+", fontsize=7.6)
a2.set_title("Confident calls", fontsize=8.6)
a2.grid(axis="y", visible=False)
fig.tight_layout(w_pad=0.8)
plt.show()
Figure 74.3: What the measurable steps are worth for a run-or-pass call. Each row adds one checklist step to a model learned on the 2024 regular season and then scored on every 2025 regular-season run and dropback (32,813 snaps). Left: how often the call was right. Right: the share of snaps the model could call at 80% confidence or more, and (in the label) how often those confident calls were right. The situation does most of the work; the formation (quarterback’s alignment and number of backs) is the one big jump after it; motion, tempo and the box add almost nothing to run or pass. Source: nflverse play-by-play, FTN charting and participation.

Three lessons hide in that chart.9

The situation is most of the call. Down, distance, field zone, score and clock take you from 60% (“always pass”) to 69%. That is the step a casual fan skips because it feels too obvious to count.

The formation is the one big jump after it. Adding the quarterback’s alignment and the number of backs lifts the model to 75%, and, as important, raises the share of snaps it can call at 80% confidence or better from 28% to 44%. Personnel alone added little on top of the situation (about 0.5 points), because most of what the personnel tells you, the formation tells you again, more precisely.

Motion, tempo and the box add almost nothing to run or pass. Together they move the needle by 0.3 points. Not because they’re useless: they answer other questions. Motion identifies the coverage; the box says whether a run, if it comes, has a chance (and partly repeats the formation, since the defense set it in reply). The same is likely true of the steps nobody charts: safety depth and corner leverage say little about whether the offense will throw, and a great deal about where and against what.

That changes how you should spend your twenty seconds. By the end of step 3 your run-or-pass call is essentially made. Steps 4 to 8 are for the second layer: the coverage, the pressure, where the ball should go, which way the run will hit. A reader who spends all twenty seconds agonising over run or pass is using the expensive steps on the cheap question.

The tells catalogue

How to Watch a Broadcast started a catalogue with four starter tells (stance, the back’s depth and offset, splits, safety depth) and promised the full version here. This is it: ten clues, five from each side of the ball, and a plate that shows where each one lives.

Show the Python
off = offense("gun_2x2")
OMOVE = {"Y": (-0.7, 4.8), "Z": (-1.5, 9.0), "X": (-0.7, -15.5), "H": (-1.5, -9.0), "RB": (-5.2, 1.7)}
off = [p_.moved(d=OMOVE[p_.key][0], w=OMOVE[p_.key][1]) if p_.key in OMOVE else p_ for p_ in off]
dfn = defense("nickel", "two_high", off)
DMOVE = {"WDE": (1.0, -8.2), "WDT": (1.0, -2.4), "SDT": (1.0, 1.0), "SDE": (1.0, 5.6), "MIKE": (3.0, 1.2),
         "WILL": (5.0, -2.6), "NB": (5.0, -6.3), "LCB": (7.0, -16.6), "RCB": (1.0, 8.3), "FS": (13.5, -9.5),
         "SS": (10.5, 3.4)}
dfn = [p_.moved(d=DMOVE[p_.key][0], w=DMOVE[p_.key][1]) if p_.key in DMOVE else p_ for p_ in dfn]
dfn = [p_.moved(label="CB") if p_.pos == "CB" else p_ for p_ in dfn]
pl = Play(off, dfn, los=40, to_go=None)
fig = plt.figure(figsize=(6.5, 6.35))
axf = fig.add_axes([0.0, 0.29, 1.0, 0.71])
axk = fig.add_axes([0.0, 0.0, 1.0, 0.29])
blank(axk)
fld = pl.draw(ax=axf, lateral=(-24, 24), window=(-8.5, 17.0), title=False)
no_numbers(fld.ax)
# where Z would usually stand: on the numbers, about 15 yards from a ball in the middle
gx, gy = fld.xy(*pl.to_abs(-1.5, 15.0))
fld.ax.add_patch(Circle((float(gx), float(gy)), 0.75, fc="none", ec=style.OFFENSE, lw=0.9, ls=(0, (2, 1.5)), zorder=6))
zx, zy = fld.xy(*pl.to_abs(-1.5, 9.0))
fld.ax.add_patch(FancyArrowPatch((float(gx), float(gy)), (float(zx), float(zy)), arrowstyle="-|>", mutation_scale=7,
                                 color=style.OFFENSE, lw=0.9, ls=(0, (2, 1.5)), shrinkA=4, shrinkB=5, zorder=6))
for n, at in [(1, (-2.7, 3.2)), (2, (-5.2, 3.6)), (3, (-5.0, -1.9)), (4, (-3.6, 12.0)), (5, (-2.6, 6.2)),
              (6, (2.9, -8.2)), (7, (3.0, 3.1)), (8, (6.9, -7.2)), (9, (2.9, 10.3)), (9, (8.9, -17.4)),
              (10, (10.5, 5.6)), (10, (13.5, -11.6))]:
    fbadge(fld, pl, n, at, r=1.05, fs=6.8)
tag(fld, pl, "hash", (15.5, 3.1), fs=6.0, color=style.INK_2)
tag(fld, pl, "hash", (15.5, -3.1), fs=6.0, color=style.INK_2)
for d_ in (0, 5, 10, 15):
    tag(fld, pl, "LOS" if d_ == 0 else f"{d_} yd", (d_ + (0.9 if d_ == 0 else 0.0), -21.2), fs=5.8, color=style.INK_2)
KEY = [
    (1, "Linemen's stance", "Heavy hand: run. Hips back: pass. Guard set back: a pull."),
    (2, "Back's depth and offset", "Gun runs usually go away from his side."),
    (3, "Gun, pistol, under center", "Pass rates: gun " + pct(RATE["gun"]) + ", pistol " + pct(RATE["pistol"])
        + ", under center " + pct(RATE["uc"]) + "."),
    (4, "Receiver split", "Reduced: a crack block, a crosser, or an out-breaker."),
    (5, "Tight end's stance", "Hand down: block. Standing, flexed: route."),
    (6, "Lineman's width and weight", "Wide: rush. Sitting back: a stunt or a drop."),
    (7, "Linebacker depth", "Creeping up: blitz or run fit. Backing out: zone drop."),
    (8, "Nickel's spot", "Apexed: zone or run. Tight on the slot: man or blitz."),
    (9, "Corner depth and leverage", "Off and outside: help inside. Inside: usually man."),
    (10, "Safety depth and width", "Wide: a half or quarter. Shallow or pinched: the rotator."),
]
axk.set_xlim(0, 100)
axk.set_ylim(0, 100)
axk.text(2.5, 95.0, "Offense", fontsize=8.5, fontweight="bold", color=style.OFFENSE_DARK, va="center")
axk.text(52.5, 95.0, "Defense", fontsize=8.5, fontweight="bold", color=style.DEFENSE_DARK, va="center")
for i_, (n, title, txt) in enumerate(KEY):
    col = 0 if n <= 5 else 1
    row = (n - 1) % 5
    x0 = 2.5 + 50 * col
    y0 = 82.0 - row * 18.5
    axk.scatter([x0 + 1.4], [y0], s=125, color=LOOK, edgecolor="white", linewidth=0.8, zorder=5)
    axk.text(x0 + 1.4, y0, str(n), ha="center", va="center", fontsize=6.0 if n < 10 else 5.4, color="white",
             fontweight="bold", zorder=6)
    axk.text(x0 + 4.4, y0 + 2.6, title, ha="left", va="center", fontsize=7.4, fontweight="bold", color=style.INK)
    axk.text(x0 + 4.4, y0 - 4.6, txt, ha="left", va="center", fontsize=6.6, color=style.INK_2)
plt.show()
Figure 74.4: The tells catalogue on one plate. A shotgun offense with 11 personnel (one back, one tight end) faces a nickel defense with two deep safeties. Numbered badges mark where each tell lives: 1 to 5 on the offense, 6 to 10 on the defense; the key below gives the usual meaning in a few words, and the table after the next figure gives the reasons. Every one is a lean, not a guarantee, and some alignments are exaggerated so the clue is visible. Badge 1 sits behind the right tackle, whose stance you read from the side (next figure); Z (4) has cut his split from the numbers (faint circle) to about nine yards from the ball; the left end (6) is aligned well outside the tackle; the right corner is pressed with inside leverage and the left corner is off with outside leverage (9); the Mike has crept to three yards (7); and the right safety (10) is shallower than his partner and pinched to the hash, while the left one stands at normal width. The depth scale on the left counts yards from the line of scrimmage.

Reading the plate offense first, then defense, is deliberate: it is the order of the checklist. Offensive tells belong to step 3, when everyone is set; defensive tells to steps 6 to 8, when the defense has settled (or pretended to). Two of the stance tells are easier to understand from the side:

Show the Python
fig, ax = plt.subplots(figsize=(6.5, 2.35))
blank(ax)
ax.set_xlim(0, 48)
ax.set_ylim(-5.4, 12.0)
ax.set_aspect("equal")
ax.plot([0.5, 47.5], [2, 2], color=style.BASELINE, lw=1.2)
LW = 2.6
WEIGHT = style.DEFENSE_DARK


def stick(x0, pts, head, col=style.OFFENSE):
    fb, ff, kb, kf, hip, sh, hand = pts
    for a, b in ((fb, kb), (kb, hip), (ff, kf), (kf, hip), (hip, sh), (sh, hand)):
        ax.plot([x0 + a[0], x0 + b[0]], [a[1], b[1]], color=col, lw=LW, solid_capstyle="round")
    ax.add_patch(Circle((x0 + head[0], head[1]), 0.85, fc=col, ec="none"))


def weight(x, y):
    ax.add_patch(Circle((x, y), 0.42, fc=WEIGHT, ec="white", lw=0.5, zorder=5))
    ax.annotate("", xy=(x, y + 0.5), xytext=(x, y + 2.3), arrowprops=dict(arrowstyle="-|>", color=WEIGHT, lw=1.3),
                zorder=5)


def los(x):
    ax.plot([x, x], [2.0, 9.4], color=style.INK_2, lw=0.9, ls=(0, (3, 2)))
    ax.text(x, 9.9, "LOS", ha="center", va="bottom", fontsize=5.8, color=style.INK_2)


# A: tackle, heavy three-point, feet square
stick(5.0, [(-2.0, 2), (0.3, 2), (-1.3, 4.0), (1.0, 3.8), (-1.3, 5.9), (2.8, 5.4), (2.1, 2.15)], (3.7, 5.9))
weight(5.0 + 2.1, 1.75)
los(5.0 + 4.8)
# B: tackle, crouched two-point pass set: outside foot far back, hips low, hands up, weight on the back foot
stick(17.0, [(-3.6, 2), (0.5, 2), (-2.6, 3.7), (1.0, 3.8), (-1.5, 5.0), (0.6, 7.6), (2.1, 6.3)], (1.2, 8.6))
weight(17.0 - 3.6, 1.75)
los(17.0 + 3.6)
# C: tight end, three-point on the line
stick(29.0, [(-2.3, 2), (0.1, 2), (-1.5, 4.1), (0.8, 4.0), (-1.7, 6.4), (2.2, 6.6), (2.0, 2.15)], (3.2, 7.2))
weight(29.0 - 0.9, 1.75)
los(29.0 + 3.6)
# D: tight end, two-point, a yard (about 4.5 units at this scale) off the line
stick(38.0, [(-1.6, 2), (0.7, 2), (-1.1, 4.3), (1.0, 4.3), (-0.3, 6.2), (0.5, 8.6), (1.5, 7.2)], (0.7, 9.5))
weight(38.0 + 0.7, 1.75)
los(38.0 + 6.4)
ax.annotate("", xy=(38.0 + 6.3, 2.9), xytext=(38.0 + 2.0, 2.9),
            arrowprops=dict(arrowstyle="<->", color=style.INK_2, lw=0.8))
ax.text(38.0 + 4.15, 3.4, "1 yd", ha="center", va="bottom", fontsize=5.8, color=style.INK_2)
ax.plot([24.6, 24.6], [-5.0, 11.4], color=style.GRID, lw=1.0)
ax.text(1.0, 11.2, "The tackle", ha="left", va="center", fontsize=7.4, fontweight="bold", color=style.INK)
ax.text(25.6, 11.2, "The tight end", ha="left", va="center", fontsize=7.4, fontweight="bold", color=style.INK)
for x_, t in ((5.6, "A  square, heavy hand\n→ run block?"),
              (17.4, "B  outside foot back,\ncrouched two-point\n→ pass set?"),
              (29.6, "C  hand down,\non the line\n→ block?"),
              (40.6, "D  standing,\na yard off the line\n→ route?")):
    ax.text(x_, 0.9, t, ha="center", va="top", fontsize=6.3, color=style.INK, linespacing=1.12)
plt.show()
Figure 74.5: Two stance tells seen from the side. Each player faces right, toward his own line of scrimmage (the dashed line in front of him); the dot and arrow mark where his weight is. A and B, the tackle: about to drive forward, he stands square with his shoulders over a heavy hand; about to kick back into a pass set, he staggers his outside foot well back and sits on it, and on obvious passing downs many tackles go further and stand up in a crouched two-point stance, as here, which is the easiest version to spot on TV. C and D, the tight end: in a three-point stance on the line he is set up to block (though a good one releases from the same stance); standing a yard off the line, he is set up to get into a route quickly. The tight end tell is the weaker one: many teams stand the tight end up on every snap.

Here is the whole catalogue as a reference table: what each tell usually means, why it leaks (a tell that has no physical or tactical reason behind it won’t survive a self-scout), how much to trust it, and whether you’ll see it live or only on the replay.

# Tell Usually means Why it leaks How far to trust it; where you’ll see it
1 Linemen’s stance and weight Heavy hand, weight forward: run. Light hand, hips back, a tackle’s outside foot staggered or a two-point stance, the line set a little off the ball: pass. A guard with less weight on his hand, cheated back a few inches or with his heel turned: a pull, so a gap run A 320-pound man can’t explode forward from his heels or kick back from his fingertips, and a puller needs a head start sideways Medium on early downs; quiet on short yardage, when everyone is heavy. Replay (end zone); live only on close-ups.
2 Back’s depth and offset Shotgun runs usually cross the quarterback’s face, away from the back’s side; a deeper back wants time to read The handoff path points one way; reading blocks needs a running start Medium for direction; weak for run or pass. Live, every snap.
3 Shotgun, pistol or under center Shotgun mostly pass (79% in 2025); pistol and under center mostly run Each alignment is built for a family of plays Strong for run/pass, but under-center passes are mostly play-action, built to exploit this tell. Live, every snap.
4 Receiver split Reduced: a crack block, a short crosser, or room to break outside. Wide: an isolated route, deep or breaking inside Receivers set their split to make their job easier Weak: tells you where to look more than what. Live on the wide shot; replay otherwise.
5 Tight end’s stance Hand down on the line: block likely. Standing, flexed: route likely Blocking starts low; releasing starts tall Weak; many teams stand the tight end up on every snap. Live.
6 Defensive lineman’s width and stance Wide-aligned edge: pass rush. A lineman sitting back: stunt or drop A wide rusher has a straight path to the quarterback; the looper on a stunt (the lineman who swings around his teammate) needs his weight back to go sideways Medium on obvious passing downs. Replay; live from the end-zone shot.
7 Linebacker depth Creeping to 3 yards late: blitz or a downhill run fit. Backing out early: zone drop A blitzer needs a head start; a dropper needs depth Medium; walking up is also a bluff. Live, on the wide shot.
8 Nickel’s spot Apexed: run support or zone. Tight on the slot: man, or a nickel blitz Man coverage needs proximity; an apex keeps both jobs in reach Medium. Live.
9 Corner depth and leverage Press: man or a hard corner. Off with outside leverage: help inside (a deep middle safety). Inside leverage: usually man, with no help inside (the sideline is his help) or a safety over the top A defender takes away the side where he has no help Medium; leverage also follows the play-call’s coverage of the week. Replay (skycam); live on the wide shot.
10 Safety depth and width Deep and at normal width, 8 to 10 yards from the middle: a deep half or quarter. The shallower one, the one pinched to the hash, or the one moving: the rotation A safety can’t cover more than about eight to ten yards of ground between the snap and the throw Strong when it moves late; disguise is the norm. Top edge of the TV picture; skycam replay.

A few of these deserve a sentence more than the table can give them.

The back’s offset is a direction tell, not a run-or-pass tell. Shotgun runs most often send the back across the quarterback’s face, because the mesh (the moment the ball is handed off) happens while the back is moving across. A back offset to the right usually runs left. But the same offset is where a back sets up to pass-protect against a rusher from that side, so it tells you which way a run goes far more than whether there will be one. (No public charting records the back’s offset, so this one you’ll have to calibrate in your own log.)

The run’s scheme has one pre-snap tell, and it’s on a guard. A gap scheme needs a puller, and a guard who is about to pull sideways often sets up for it: less weight on his hand, a few inches deeper than his neighbours, his heel turned toward where he’s going. Zone has no such tell before the snap; you confirm it at the snap, when the whole line steps the same way together (Zone Running).

The split tell is a where, not a what. A reduced split helps three different jobs (a crack block, a shorter crossing route, room to break outside), so it can’t say which is coming. But when a receiver tightens his split on a run look, watch the defender inside him: that’s the man he’s coming to block.

Leverage points at the help. A defender usually lines up to take away the side where he has no help, and funnels the receiver toward the side where he has some. So a corner with outside leverage and a single safety in the middle is telling you the safety is his help. Inside leverage is the man-coverage look, and it comes in two kinds. With nobody in the middle (Cover 0, much press Cover 1), the corner takes away the quick inside throw himself and uses the sideline as his help. With two safeties deep, he can sit inside and trail the receiver (trail technique) because a safety is over the top: that’s 2-Man. Read leverage as an arrow pointing at the help, and the coverage family usually follows.

Safety width is the most underused tell. Everyone counts deep safeties; few look at how far apart they are. NFL hash marks are only about three yards either side of the middle of the field, so use the numbers as your ruler. A two-high safety at normal width, eight to ten yards from the middle (about halfway between the hash and the top of the numbers), has a deep half or deep quarter to cover and can’t reach the deep middle in time. A safety pinched to the hash or inside it, within three to five yards of the middle, can, and the one standing shallower than his partner can come down. That asymmetry, not the two-high picture itself, is how you see a rotation coming.

CautionCommon misconception

“NFL players are too good to have tells.” Every player has tendencies, because the best way to do a job quickly is to start in the position the job needs. What separates good teams from bad ones isn’t the absence of tells, it’s what they do about them. Every staff self-scouts its own film looking for the clue an opponent could read, and then decides whether to break it, hide it, or keep it as bait (Constraint Theory and Sequencing). The Rams’ under-center passing in 2025 is the textbook bait: they lined up under center on 59% of their snaps (the league: 34%), threw on 40% of those (the league: 31%), and made 81% of those throws play-action. The alignment says run, so the linebackers step up, and the pass goes behind them. A tell that is right 70% of the time is still worth using. You just shouldn’t be 95% sure of it.

Stated confidence: putting a number on it

Step 9 asks for a number, and this is where the course’s most important habit gets its name. A stated confidence is the probability you attach to a prediction before the snap, said or written as a number rather than a feeling: not “probably a pass” but “pass, 75%.” The prediction log has had a confidence column since Part 1. From here on, it is not optional.

Why a number, rather than just the call?

  1. It separates a read from a guess, over many calls. A single miss tells you almost nothing: one 90% call in ten should miss. A pile of them is different. If your 90% calls miss one time in three, you’re misreading something; if your 55% calls miss half the time, you were honestly unsure, and you said so. Without the number, your log can’t tell those two patterns apart.
  2. It forces you to use the base rate. You can’t say “80%” for a pass on first-and-10 without asking what moved you from 50. If nothing did, the honest number is 50 to 55.
  3. It can be scored. A forecaster who says “70% chance of rain” isn’t wrong when it stays dry; she’s wrong if, across all her 70% days, it rains on nine in ten, or on three in ten. Your calls work the same way: a 70% call should come true about seven times in ten. The next chapter, Film Study, scores the season’s log that way, with a Brier score (the average squared gap between your stated confidence and what happened) and a calibration plot (do your 70% calls come true about 70% of the time?). Researchers who study forecasters have found that saying the number, rather than a word like “probably”, is itself one of the habits that makes forecasters better.10

How do you choose the number? Start from the base rate of step 1, move it with the strong clues (the formation is the big one), and then respect two limits:

  • Never below 50. Your call is whichever side you think is more likely, so its confidence is at least 50%. A 50% call is perfectly honest: it says “I have nothing”, and logging it is better than skipping the snap.
  • Rarely above 90. Offenses choose when to break their tendencies, and the snaps that break them are the ones built to fool you. Even the most lopsided situations leave room for a surprise: third-and-7 to 10 is a pass 94% of the time, not 100%.

What does a well-judged spread of confidences look like? Here is the model from the previous section (steps 1 to 3 only: situation, personnel and formation) on every 2025 snap, grouped by how sure it was:

Show the Python
fig, ax = plt.subplots(figsize=(6.5, 2.35))
labs = [f"{int(b['lo'] * 100)}–{int(round(b['hi'] * 100))}%" for b in BANDS]
ys = np.arange(len(BANDS))[::-1]
shades = style.SEQUENTIAL_BLUE[1:6]
ax.barh(ys, [b["share"] for b in BANDS], color=shades, height=0.62, zorder=2)
for y_, b in zip(ys, BANDS):
    ax.text(b["share"] + 0.006, y_, f"{pct(b['share'])} of snaps  ·  right {pct(b['acc'])}", va="center",
            ha="left", fontsize=7.4, color=style.INK)
ax.set_yticks(ys, labs, fontsize=7.8)
ax.set_ylabel("the model's confidence", fontsize=7.6)
ax.set_xlim(0, 0.52)
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0%}"))
ax.set_xlabel("share of 2025 snaps", fontsize=7.6)
ax.grid(axis="y", visible=False)
plt.show()
Figure 74.6: What honest confidence looks like for run or pass. Every 2025 regular-season run and dropback, grouped by how confident a model using only steps 1 to 3 (situation, personnel, formation; learned on 2024) was in its call. Bars: the share of snaps in each band. Labels: how often the call in that band was right. The calls come true at close to the rate they claim (two to four points less often, because the model learned on a different season), and about one snap in five really is a 90%-plus call (long-yardage downs, empty backfields, the end of halves). The rest are spread across the middle, where most of football lives. Source: nflverse play-by-play, FTN charting and participation.

That chart is the target for your own log. When the model says 70 to 80%, it’s right 71% of the time; when it says 90% or more, it’s right 94% of the time. Nobody expects a person to match a model learned on 33,000 snaps, but the shape is the goal: confident where the situation is lopsided, modest everywhere else, and right about as often as you claimed.11

But look at the top band: on a fifth of snaps the model was right 94% of the time. Doesn’t a cap at 90 make you underconfident there? A little, and that’s the point: the two mistakes don’t cost the same. On a call that comes true 94% of the time, the Brier score the next chapter uses (lower is better) is 0.056 per call if you say exactly that, 0.057 if you say 90%, and 0.058 if you say 99%. Saying 90 gives up almost nothing; saying 99 costs more even when the situation really is that lopsided, and far more when it isn’t, which is the usual human error: in study after study, people’s “90% sure” answers come true well under 90% of the time.12 So 90 is a ceiling for beginners, not a law. When Film Study shows that your 90% calls come true more than nine times in ten, raise your ceiling to 95 for the empty-backfield and third-and-long snaps that earned it.

A small vocabulary helps you say the number fast:

Say Means Typical use
“Coin flip” 50% Nothing to go on: the formation was off screen, or the clues pull both ways
“Lean” 55–60% First-and-10 from a balanced look; one weak clue
“Probably” 65–70% The formation agrees with the situation
“Likely” 75–80% A strong formation clue and a situation that points the same way
“Near certain” 85–90% Third-and-long, an empty backfield, a two-minute drill when trailing

Layered predictions

Run or pass is the first layer, and after step 3 it’s mostly decided. A good reader makes a second call on the snaps where there’s time, and states a confidence for it too:

  • On a likely pass: the coverage family (man or zone; one-high or two-high after the snap), the rush (four, or five or more), or where the ball should go (the quick throw, the sticks, a shot).
  • On a likely run: the direction (toward the strength, away from a shotgun back’s offset, at the light side of the box) or the scheme family (gap, if a guard is cheating back to pull; zone is confirmed only at the snap, when the line steps together). The hash won’t call the direction for you: runs from a hash went to the field and to the boundary about equally in 2025.

Second-layer calls are harder, and your confidence should show it. Their defaults come from base rates too. In 2025 defenses sent five or more rushers on only 27% of dropbacks (39% on third-and-7 to 10), so “four rush” is the default until a pressure tell moves you off it. Zone was the coverage on about 69% of charted dropbacks, so “zone” is the default, except near the goal line: man rose from 28% of dropbacks outside the red zone to 46% inside it, because a short field leaves zone defenders little deep space to sink into, and tight coverage on every receiver is the natural answer to short throws. (FTN’s man share jumps around between seasons in a way that looks like charting rather than football, so treat these as rough.13) A second-layer call at 60–70% is a good call; anyone who claims 90% on coverages before the snap is watching a very predictable defense or not keeping score.

NoteMadden vs. real life

In Madden you always know one of the two calls, your own. Watching a real game you know neither, and neither does anyone on the field: the quarterback’s pre-snap read is this checklist, and he gets it wrong often enough that offenses build hot routes and post-snap reads for the times he does. So a probability isn’t a hedge. It is the most accurate description of what anyone, including the players, knows before the snap.

CautionCommon misconception

“My 90% call missed, so I was wrong.” Not necessarily: one in ten 90% calls should miss. You can’t judge a single probabilistic call by its outcome, only a pile of them. That’s why the log matters, and why Film Study scores a season of calls rather than a play. The mistakes to look for are patterns: 90% calls that miss one time in three (overconfidence), or a log full of 55% calls that come true 75% of the time (you know more than you’re admitting).

Film room: one drive, nine snaps

NoteFilm room: Seahawks 2025 — the NFC Championship Game

The game: Rams at Seahawks, NFC Championship Game, January 25, 2026, at Lumen Field. Seattle, the NFC’s top seed (14–3), won 31–27 and two weeks later beat New England 29–13 in Super Bowl LX.14 In 2025 Seattle’s head coach was Mike Macdonald, its offensive coordinator Klint Kubiak (whose system comes from the Shanahan family tree; see Shanahan’s Wide Zone Tree) and its quarterback Sam Darnold.15

The drive: Seattle had gone up 24–13 early in the third quarter; the Rams answered with a touchdown to make it 24–20 with 9:45 left in the quarter. After the touchback, Seattle drove 65 yards in nine snaps and scored on third-and-3 from the Rams’ 13, a 13-yard pass to Cooper Kupp, to go up 31–20 with 4:52 left in the third.

Why this drive: it has a bit of everything the checklist is for: three different personnel groups, under center and shotgun, an empty backfield, a screen against a blitz, play-action, a no-huddle snap, two third downs and a red-zone touchdown, against a defense that played base, nickel and dime and three different coverage labels.

The sources: every fact below comes from the public charting in nflverse;16 the diagrams are typical pictures of what was charted, not the film.

Here is the drive as your log would record it. The calls weren’t made by watching the game. They were made by rule from the pre-snap columns only, so hindsight can’t creep in: the run-or-pass call and its confidence come from the step 1 to 5 model, fitted on the 2024 and 2025 regular seasons; the coverage and rush calls are the 2025 base rates for that down, distance, field zone and number of defensive backs.

Show the Python
COLS = [("#", 2.2), ("Situation", 10.0), ("Offense shows", 11.4), ("Defense", 6.2), ("Call", 5.4),
        ("If pass", 9.6), ("What happened", 12.2), ("R/P", 3.8), ("Pass layer", 7.0)]


def dpers(n):
    return {4: "base", 5: "nickel", 6: "dime"}.get(int(n), "?") if pd.notna(n) else "?"


def off_txt(row, sep=" · "):
    al = {"U": "UC", "S": "gun", "P": "pistol"}.get(row.qb_location, "?")
    nb = int(row.n_offense_backfield)
    backs = "empty" if nb == 0 else f"{nb} back" + ("s" if nb > 1 else "")
    extra = [w for w, f in (("motion", row.is_motion), ("no-huddle", row.is_no_huddle)) if f]
    return f"{row.pers} · {al} · {backs}" + (sep + ", ".join(extra) if extra else "")


HASH = {"L": "L hash", "R": "R hash", "M": "middle"}


def ordinal(n):
    return {1: "1st", 2: "2nd", 3: "3rd", 4: "4th"}[int(n)]


ROWS = []
for i_, row in DRV.iterrows():
    l2 = "–"
    if row.is_pass:
        l2 = ("✓" if row.cov_hit == 1 else "✗") + "  " + ("✓" if row.rush_hit == 1 else "✗")
    ROWS.append([str(i_ + 1), f"{ordinal(row.down)} & {int(row.ydstogo)}\n{row.yrdln} · {HASH[row.starting_hash]}",
                 off_txt(row, sep="\n"),
                 f"{dpers(row.dbs)}\nbox {int(row.defenders_in_box)}", f"{row.call}\n{row.conf}%",
                 f"{row.cov_call} {row.cov_conf}%\n{row.rush_call} rush {row.rush_conf}%",
                 "\n".join(textwrap.wrap(row.result, 21)), "✓" if row.hit else "✗", l2])
fig, ax = plt.subplots(figsize=(6.5, 4.75))
blank(ax)
Wt = sum(w for _, w in COLS)
nr = len(ROWS)
RH = 2.15
H = 2.4 + 1.5 + RH * nr + 1.7
ax.set_xlim(0, Wt)
ax.set_ylim(0, H)
ax.add_patch(Rectangle((0, 0), Wt, H, fc="#fbfaf4", ec=style.BASELINE, lw=1.0))
ax.text(0.8, H - 0.95, "PREDICTION LOG  ·  NFC Championship, Rams at Seahawks  ·  Seattle offense, Q3",
        fontsize=7.2, fontweight="bold", color=style.INK, va="center")
yh = H - 2.4 - 1.5
x = 0
for name, w in COLS:
    ax.add_patch(Rectangle((x, yh), w, 1.5, fc=style.OFFENSE_TINT, ec=style.BASELINE, lw=0.6))
    ax.text(x + w / 2, yh + 0.75, name, ha="center", va="center", fontsize=6.0, fontweight="bold", color=style.INK)
    x += w
for j, vals in enumerate(ROWS):
    yy = yh - (j + 1) * RH
    x = 0
    for (name, w), v in zip(COLS, vals):
        ax.add_patch(Rectangle((x, yy), w, RH, fc="white", ec=style.GRID, lw=0.6))
        col = style.INK
        if name in ("R/P", "Pass layer"):
            col = style.THIRD if v.startswith("✓") else (style.DEFENSE_DARK if v.startswith("✗") else style.INK_2)
        left = name in ("Situation", "Offense shows", "Defense", "If pass", "What happened")
        ax.text(x + 0.45 if left else x + w / 2, yy + RH / 2, v, ha="left" if left else "center", va="center",
                fontsize=5.9, color=col, linespacing=1.15,
                fontweight="bold" if name in ("R/P", "Pass layer", "#", "Call") else "normal")
        x += w
ax.text(0.8, 0.85, f"Run/pass: {N_HIT} of {nr} right.   Pass layer on the {int(DRV.is_pass.sum())} dropbacks: "
        f"coverage {N_COV} of {int(DRV.is_pass.sum())}, rushers {N_RUSH} of {int(DRV.is_pass.sum())}.",
        fontsize=6.8, color=style.INK, va="center")
plt.show()
Figure 74.7: The drive as a prediction log: Seattle’s nine snaps from its own 35 to the end zone, NFC Championship Game, third quarter. Situation, offense and defense are the pre-snap facts in the charting (L/R hash = the ball spotted on the offense’s left or right hash, so the field is the other way; UC = under center; M = motion; NH = no-huddle; box = defenders in the box). The calls were made by rule from those facts alone: run or pass from the checklist model, the ‘if pass’ layer (coverage family, rushers) from 2025 base rates for the situation and the number of defensive backs. The ‘if pass’ layer is filled in on every snap, because you make it before you know whether the ball will be thrown; the last two columns score the calls, and the pass layer is scored only on dropbacks, because coverage isn’t charted on runs. Source: nflverse play-by-play, participation and FTN charting, game 2025_21_LA_SEA.

The same drive in pictures, one pre-snap look per snap:

Show the Python
SNAP_LOOK = {1: ("12_1", 5, 2, "two"), 2: ("12_2", 5, 1, "two"), 3: ("11_1", 4, 1, "two"), 4: ("13_2", 5, 2, "two"),
             5: ("11_0", 4, 2, "one"), 6: ("11_1", 4, 2, "one"), 7: ("12_2", 5, 2, "one"), 8: ("12_1", 5, 2, "one"),
             9: ("11_1L", 4, 2, "two")}
SNAP_RING = {1: "RB", 2: "RB", 3: "H", 4: "RB", 5: "RB", 6: "H", 7: "FB", 8: "X", 9: "H"}


def look_offense(kind):
    if kind == "12_1":
        return offense("ace")
    if kind == "12_2":       # a tight end as an offset H-back (two backs in the backfield)
        return [p_.moved(w=-1.5, pos="TE") if p_.key == "FB" else p_ for p_ in offense("offset_i")]
    if kind == "13_2":       # three tight ends: two in line, one as an H-back; one receiver
        o = [p_ for p_ in offense("ace") if p_.key != "X"]
        return o + [Player("FB", "TE", "off", -4.5, 1.5, label="F")]
    if kind == "11_1":
        return offense("gun_2x2")
    if kind == "11_1L":
        return offense("gun_2x2", strength="left")
    if kind == "11_0":
        return offense("gun_empty")
    raise KeyError(kind)


def look_defense(off, line, lbs, shell, dbs, rz=False):
    LINE = {4: [-5.8, -2.2, 1.0, 5.8], 5: [-6.2, -2.6, 0.0, 2.6, 6.2]}
    LB = {1: [0.8], 2: [-2.4, 2.4]}
    out = [Player(f"DL{k}", "DT", "def", 1.0, w) for k, w in enumerate(LINE[line])]
    out += [Player(f"LB{k}", "LB", "def", 4.5, w) for k, w in enumerate(LB[lbs])]
    rec = [p_ for p_ in off if p_.pos in ("WR", "TE", "RB") and abs(p_.w) > 6.0]
    left = sorted([p_ for p_ in rec if p_.w < 0], key=lambda p_: p_.w)
    right = sorted([p_ for p_ in rec if p_.w > 0], key=lambda p_: -p_.w)
    cb_d, s_d, ss_d = (5.5, 9.5, 7.5) if rz else (7.0, 12.5, 9.0)     # a short field compresses the secondary
    out += [Player("CBL", "CB", "def", cb_d, (left[0].w if left else -11.0) - 0.8),
            Player("CBR", "CB", "def", cb_d, (right[0].w if right else 11.0) + 0.8)]
    slots = left[1:] + right[1:]
    for k in range(dbs - 4):
        w = slots[k].w + (0.6 if slots[k].w < 0 else -0.6) if k < len(slots) else (-7.5, 7.5)[k % 2]
        out.append(Player(f"NB{k}", "NB", "def", 5.5, w))
    if shell == "two":
        out += [Player("FS", "FS", "def", s_d, -8.5), Player("SS", "SS", "def", s_d, 8.5)]
    else:
        out += [Player("FS", "FS", "def", s_d + 0.5, 0.0), Player("SS", "SS", "def", ss_d, 8.5)]
    return out


BALL_Y = {"L": "left", "R": "right", "M": "middle"}


fig, axes = plt.subplots(3, 3, figsize=(6.5, 5.6))
for k, ax in enumerate(axes.flat, start=1):
    row = D.loc[k]
    kind, line, lbs, shell = SNAP_LOOK[k]
    o = [p_.moved(label="") for p_ in look_offense(kind)]
    o = [p_.moved(w=2.4 * np.sign(p_.w)) if p_.key == "RB" and p_.d == -5.0 else p_ for p_ in o]   # gun: R beside Q
    dfn = [p_.moved(label="") for p_ in look_defense(o, line, lbs, shell, int(row.dbs), rz=row.yardline_100 <= 20)]
    los = 110 - row.yardline_100
    pk = Play(o, dfn, los=los, to_go=int(row.ydstogo), ball_y=BALL_Y[row.starting_hash])
    pk.highlight(SNAP_RING[k])
    fld = pk.draw(ax=ax, lateral=(-21, 21), window=(-8.5, 15.5), title=False)
    no_numbers(ax)
    ppy = fld.pts_per_yard()
    for p_ in o:                                     # the backfield, drawn so it reads at print size
        if p_.d < -1.6:
            bx_, by_ = fld.xy(*pk.to_abs(p_.d, p_.w))
            ax.add_patch(Circle((float(bx_), float(by_)), 0.95, fc=style.OFFENSE_DARK, ec="white", lw=0.6, zorder=9))
            lab = {"QB": "Q", "RB": "R"}.get(p_.pos, "F")
            ax.text(float(bx_), float(by_), lab, ha="center", va="center", fontsize=5.4, color="white",
                    fontweight="bold", zorder=10)
    ax.set_title(f"{k} · {ordinal(row.down)} & {int(row.ydstogo)}, {row.yrdln}, {HASH[row.starting_hash]}",
                 fontsize=7.6, loc="left",
                 color=style.INK, fontweight="bold", pad=2)
    short = off_txt(row).replace("motion", "M").replace("no-huddle", "NH")
    ax.text(0.0, -0.03, short + f"  vs  {dpers(row.dbs)}, box {int(row.defenders_in_box)}",
            transform=ax.transAxes, ha="left", va="top", fontsize=5.9, color=style.INK_2)
    ok = row.hit
    ax.text(0.0, -0.13, f"call {row.call.lower()} {row.conf}%  →  {row.result}", transform=ax.transAxes,
            ha="left", va="top", fontsize=5.9, color=style.THIRD if ok else style.DEFENSE_DARK, fontweight="bold")
fig.subplots_adjust(left=0.01, right=0.99, top=0.965, bottom=0.07, hspace=0.5, wspace=0.06)
plt.show()
Figure 74.8: The drive in pictures: a typical pre-snap look for each of the nine snaps, built from what was charted (offensive personnel, quarterback alignment, number of backs, defensive personnel, box count and the hash, all exact), with the ball on the right yard line and hash and the yellow line to gain. The players in the backfield are drawn dark and lettered (Q quarterback, R running back, F a fullback or a tight end lined up as an H-back), because one back or two is the formation clue on most of these snaps. Everything else is illustrative: the charting doesn’t record exact alignments, and for runs the safeties are drawn two-high because the coverage isn’t charted on runs; for passes the shell matches the charted coverage family. In the red zone (snaps 7 to 9) the secondary is drawn closer to the line, because the end zone is behind it. The ringed player is the ball carrier or the receiver the ball went to. Offense: blue circles; defense: orange squares.

And here is how each run-or-pass call was built, step by step, the way the confidence should move in your head:

Show the Python
fig, ax = plt.subplots(figsize=(6.5, 3.75))
n = len(DRV)
ys = np.arange(n)[::-1]
MARK = [dict(marker="o", s=36, facecolor="white", edgecolor=style.INK_2, lw=1.0, label="situation only"),
        dict(marker="s", s=16, color=style.INK_2, label="+ personnel"),
        dict(marker="^", s=42, color=style.THIRD, label="+ formation"),
        dict(marker="D", s=16, color=style.INK_2, label="+ motion, tempo"),
        dict(marker="o", s=52, color=style.OFFENSE_DARK, label="+ box: the final call")]
ax.axvline(0.5, color=style.INK_2, lw=0.9, ls=(0, (3, 2)), zorder=1)
ax.text(0.5, n - 0.35, "coin flip", ha="center", va="bottom", fontsize=6.6, color=style.INK_2)
for y_, (i_, row) in zip(ys, DRV.iterrows()):
    pts = [row[f"p{k}"] for k in range(len(TRACE))]
    ax.plot(pts, [y_] * len(pts), color=style.BASELINE, lw=1.4, zorder=2)
    for k, (v, mk) in enumerate(zip(pts, MARK)):
        kw = {kk: vv for kk, vv in mk.items() if kk != "label"}
        ax.scatter([v], [y_], zorder=3 + k, **kw)
    ax.text(-0.02, y_, f"{i_ + 1}  {ordinal(row.down)} & {int(row.ydstogo)}, {row.yrdln}", ha="right", va="center",
            fontsize=6.9, color=style.INK)
    ok = row.hit
    ax.text(1.02, y_, ("✓ " if ok else "✗ ") + ("pass" if row.is_pass else "run") + f": {row.result}",
            ha="left", va="center", fontsize=6.6, color=style.THIRD if ok else style.DEFENSE_DARK)
ax.set_xlim(0, 1)
ax.set_ylim(-0.7, n - 0.1)
ax.set_yticks([])
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v:.0%}"))
ax.set_xlabel("chance of a pass, as the checklist goes on", fontsize=7.4)
ax.grid(axis="y", visible=False)
ax.spines["left"].set_visible(False)
handles = []
for mk in MARK:
    face = mk.get("facecolor", mk.get("color"))
    edge = mk.get("edgecolor", mk.get("color"))
    handles.append(Line2D([], [], marker=mk["marker"], ls="", mfc=face, mec=edge, ms=np.sqrt(mk["s"]) * 0.95,
                          label=mk["label"]))
ax.legend(handles=handles, loc="upper center", bbox_to_anchor=(0.5, -0.17), ncol=5, fontsize=6.4, frameon=False,
          handletextpad=0.3, columnspacing=1.0)
fig.subplots_adjust(left=0.24, right=0.72, bottom=0.2, top=0.95)
plt.show()
Figure 74.9: How the run-or-pass confidence moved through the checklist on each snap of the drive. Each row is one snap, and each marker adds one step: the hollow circle is the probability of a pass from the situation alone, the small square adds personnel, the triangle adds the formation (quarterback alignment and number of backs), the diamond adds motion and tempo, and the large filled circle adds the box count and is the final pre-snap call. The right-hand label is what happened, ticked or crossed against the call. The biggest moves come at the triangle, the formation step: snap 5 (an empty backfield) jumps toward pass and snaps 2, 4 and 7 (two backs under center) drop toward run. The misses are the second-and-1 screen (a coin flip that landed the other way) and snaps 7 and 8, passes from under center with two tight ends, exactly the look offenses use to break the tendency. Model fitted on the 2024 and 2025 regular seasons; source: nflverse play-by-play, participation and FTN charting.

Now walk it the way you would live, step by step. The snap numbers match the log.

Snap 1: first-and-10 at the Seattle 35, left hash. 12 personnel (Kupp and Jake Bobo the receivers) against base; under center, one back, motion: a run look, but only run, 55%, because offenses throw from it too. George Holani runs behind the right guard for three. ✓

Snap 2: second-and-7. Second-and-medium leans pass, but Seattle stays in 12 personnel with a second player in the backfield, most likely a tight end lined up as an H-back (in the backfield, just off the tackle’s hip). Two backs under center swings the call back to run, 60%. The Rams answer with a nickel, which looks like the defense betting on a pass, until you notice who came on. On snap 1 the receivers were Kupp and Bobo; now they are Kupp and Jaxon Smith-Njigba, the league’s 2025 Offensive Player of the Year. On this drive every snap with Smith-Njigba on the field drew a nickel or a dime, and every 12-personnel snap without him drew base.17 The Rams were matching a player, not a digit: that’s the “notice which receivers” of step 2. Kenneth Walker loses two around the right end. The call was right; the run wasn’t, and you’re scoring your read, not the offense.

Snap 3: third-and-9 at the 36, right hash. The run-or-pass question answers itself (pass, 90%), and everything after step 3 goes into the second layer. Seattle goes to 11 personnel in the shotgun; the Rams go to dime (six defensive backs) with only five men in the box, a defense that wants to keep everything in front of it and tackle short of the sticks. The base-rate calls say zone and a four-man rush, and that’s what came: four rushers, and a zone FTN labelled Cover 9, a number that means different things to different staffs (Coverage Fundamentals); the figure draws the Fangio meaning, a weak-rotation Cover 3 (Fangio’s Two-High Revolution). Darnold held the ball 2.8 seconds and found Kupp short to the left, toward the field, a throw FTN charted as a checkdown: six yards in the air and six after the catch, a first down by three yards.

Show the Python
def snap3():
    off = offense("gun_2x2")
    dl = [("OLB1", "OLB", 1.0, -6.6), ("DT1", "DT", 1.0, -2.0), ("DE1", "DE", 1.0, 1.2), ("OLB2", "OLB", 1.0, 6.0)]
    dfn = [Player(k, pos, "def", d, w, label="T" if pos == "DT" else "E") for k, pos, d, w in dl]
    dfn += [Player("MIKE", "MIKE", "def", 5.5, 0.6),
            Player("LCB", "CB", "def", 7.5, -15.8, label="CB"), Player("RCB", "CB", "def", 7.5, 15.8, label="CB"),
            Player("ND", "DB", "def", 6.0, -9.6, label="$"), Player("DM", "DB", "def", 6.0, 9.6, label="D"),
            Player("FS", "FS", "def", 13.0, -8.5), Player("SS", "SS", "def", 13.0, 8.5)]
    p = Play(off, dfn, los=46, to_go=9, ball_y="right")
    p.highlight("H")
    p.route("X", r.go(20)); p.route("Z", r.curl(12)); p.route("Y", r.dig(12, 8))
    p.route("H", r.path((5.0, 0.0), (5.6, 3.5), (6.0, 11.0)), speed=4.6)   # a short out, run flat to the sideline
    p.route("H", r.path((6.0, 0.6)), speed=6.5)                          # ...and up the field after the catch
    p.block("RB", pts=[(0.6, -0.4)])
    p.dropback(2.5)
    protect(p)
    engage(p, ("OLB1", "DT1", "DE1", "OLB2"))
    # Cover 9 drawn as a weak rotation: the left (weak) safety comes down, the right one runs to the middle
    p.drop("FS", (9.0, -5.0), speed=5.0)
    p.drop("SS", (17.0, 0.0), speed=6.0)
    p.drop("LCB", (17.0, -16.6), speed=5.5); p.drop("RCB", (17.0, 15.8), speed=5.5)
    p.drop("MIKE", (8.5, 1.5)); p.drop("DM", (8.5, 11.5))
    p.drop("ND", (8.5, -12.5), speed=4.5)
    p.path("ND", [(9.0, -19.0)], speed=4.0, delay=2.8, absolute=True)   # drives on the throw
    p.read("Z")
    p.pass_to("H", t=2.8)
    return p


s3 = snap3()
animate_or_strip(s3, [0.0, 1.5, 2.85, 4.6],
                 ["Snap · dime, two-high, five in the box", "+1.5 s · three deep; QB looks right",
                  "+2.8 s · right side covered; ball goes left", "+4.6 s · twelve yards: past the sticks"],
                 lateral=(-24, 21), window=(-9.0, 19.5))
Figure 74.10: Snap 3, third-and-9 from the right hash (a reconstruction, simulated in Big Data Bowl format). Charted facts: 11 personnel in the shotgun, no motion; the Rams in dime with five in the box; four rushers; a zone labelled Cover 9; a checkdown thrown after 2.8 seconds to Cooper Kupp (H, ringed) on a short out to the left, toward the wide side of the field: six yards in the air and six after the catch for the first down. Illustrative: the alignments, the other routes, and the rotation, drawn as the weak-rotation Cover 3 meaning of ‘Cover 9’ (two-high before the snap, the safety away from the tight end coming down, three deep). Defensive letters: E, an end or edge rusher; T, a tackle; M, the one linebacker; $ and D, the nickel and dime backs; FS and SS, the safeties. What to notice: the quarterback’s eyes (the green dashed line) start on the right, where Z’s curl and Y’s dig are covered by the underneath defenders sinking to the sticks, and only then does the ball go left; the conversion comes from the yards after the catch.

That’s the usual shape of third-and-long: the run-or-pass call is free, the defense’s plan is readable from its personnel, and the play is decided by something you can’t predict, a tackle made or missed. A second-layer call of “zone, four rush, the ball goes short” was right, and that’s as good as reading gets.

Snap 4: first-and-10 at the 48, left hash. Seattle goes heavy: 13 personnel (three tight ends, one receiver), under center, two backs, motion. The Rams match with base and five linemen and edge rushers up front (two of them listed on the roster as nose tackles), a seven-man box. The call is run, 70%, as confident as a first-down run call gets, and that leaves time for a second layer: which way? Under center there’s no shotgun offset to read, so the clues are the strength (the side with more tight ends, or the H-back’s side) and the light side of the box. The charting doesn’t record which side those were, so this drive can’t score a direction call. Walker went behind the right guard for eleven. In your own log, write the direction call anyway (l2 right, 0.6): your eyes record what the charting doesn’t.

Snap 5: first-and-10 at the Rams’ 41. The reverse picture: 11 personnel, shotgun, and nobody in the backfield. An empty backfield is the strongest pass tell in the catalogue (95% of 2025 empty-backfield snaps were dropbacks), and the call is pass, 85%. The second layer is the interesting part. Empty leaves only five blockers, so a sixth rusher is unblockable and the quarterback has to beat him with a quick throw; that’s why empty is so often a quick-throw formation. The Rams rushed four and played Cover 3, and Darnold checked the ball down to Holani, who had lined up outside the backfield as a receiver, on a shallow cross for nine.

Snap 6: second-and-1 at the 32, right hash. The most interesting snap of the drive. Second-and-short is the down where offenses take chances, because even a failed play leaves a manageable third down; from the shotgun with 11 personnel it was a pass 57% of the time in 2025. The model says run, 55%. Why the disagreement? The model adds the down and the formation as separate pushes and never learns that this particular pairing, the shotgun on second-and-short, throws more than the two pushes suggest: at the formation step it says 44% where the raw rate is 57%. Narrow the raw rate to this exact look (one back, motion, six in the box) and it falls to 49%, though on only 57 snaps. Either way it’s close to a coin flip, and there’s a general lesson: when you know the base rate for the exact look, trust it over a sum of clues. The second layer said zone (70%) and a four-man rush (75%). The Rams played zone but sent five, and Darnold got the ball out in 0.7 seconds on a screen to Smith-Njigba, who caught it four yards behind the line, toward the field, and ran sixteen yards after the catch. ✗ on run or pass, and a 55% call is allowed to miss: that is what 55% means.

Show the Python
def snap6():
    off = offense("gun_2x2")
    dfn = [Player("WDE", "DE", "def", 1.0, -5.8), Player("WDT", "DT", "def", 1.0, -2.0),
           Player("SDT", "DT", "def", 1.0, 1.0), Player("SDE", "DE", "def", 1.0, 5.8),
           Player("MIKE", "MIKE", "def", 4.5, 1.8), Player("WILL", "WILL", "def", 4.5, -2.2),
           Player("NB", "NB", "def", 5.0, -9.6), Player("LCB", "CB", "def", 7.0, -15.8, label="CB"),
           Player("RCB", "CB", "def", 7.0, 15.8, label="CB"), Player("FS", "FS", "def", 13.5, 0.0),
           Player("SS", "SS", "def", 8.5, 9.6)]
    p = Play(off, dfn, los=78, to_go=1, ball_y="right")
    p.highlight("H")
    p.motion("Y", to=(-1.5, -5.6), speed=5.0)                # tight end across: three to the left
    p.motion("SS", to=(8.0, 2.0), speed=3.0)                 # the safety bumps over: a zone answer
    # defense: five rush, Cover 3 behind
    for k in ("WDE", "WDT", "SDT", "SDE"):
        p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)
    p.rush("WILL", pts=[(-3.5, 0.6), (-7.1, -2.8)], speed=5.0)  # at the line when the ball comes out, then wide
    p.drop("FS", (14.0, -2.0), speed=3.0)
    p.path("FS", [(15.0, -16.0)], speed=6.0, delay=0.5, absolute=True)
    p.path("LCB", [(3.6, -14.0)], speed=3.0, delay=0.3, absolute=True)   # comes up and meets X's block
    p.drop("RCB", (13.0, 15.0), speed=5.0)
    p.path("NB", [(1.5, -11.6)], speed=4.5, absolute=True)
    p.drop("MIKE", (6.0, 0.0))
    p.path("MIKE", [(10.6, -12.8)], speed=5.5, delay=0.6, absolute=True)   # the inside linebacker makes the tackle
    p.drop("SS", (8.5, -3.0), speed=4.0)
    # offense: the screen
    protect(p)
    p.block("RB", pts=[(0.5, 0.4)])
    p.route("H", r.path((-1.5, 1.6), (-2.5, 3.6)), speed=4.0)
    p.route("H", r.path((16.0, 2.4)), speed=7.0)
    p.block("X", pts=[(3.2, 0.9)], speed=3.0)
    p.block("Y", target="NB")
    p.route("Z", r.go(14))
    p.dropback(0.8)
    p.pass_to("H", t=0.7)
    return p


s6 = snap6()
animate_or_strip(s6, [-1.6, 0.0, 0.75, 3.0],
                 ["−1.6 s · motion: the tight end goes left", "Snap · three to the left; one safety deep",
                  "+0.7 s · ball out; the Will is still at the line", "+3.0 s · sixteen yards after the catch"],
                 lateral=(-23, 21), window=(-8.5, 18.0))
Figure 74.11: Snap 6, second-and-1 from the right hash (a reconstruction, simulated in Big Data Bowl format). Charted facts: 11 personnel in the shotgun with motion; the Rams in nickel with six in the box; five rushers (one of them a blitzer) and Cover 3 behind them; a screen to Jaxon Smith-Njigba (H, ringed) on the left, toward the field, thrown 0.7 seconds after the snap, caught four yards behind the line, sixteen yards after the catch, tackled by an inside linebacker. Illustrative: who motioned (here the tight end, Y, across to the left), which defender blitzed (here the Will linebacker, W), and the blocks. What to notice: the third panel, when the ball is already out and the blitzer has only reached the line; with him gone, the screen side has three Seattle players (X and Y blocking, H with the ball) against two Rams defenders, the corner and the nickel.

Why does a screen beat a blitz? Because every extra rusher is a defender who isn’t covering anyone. This was a perimeter screen, thrown at once: the blitzing Will was one fewer Ram on the outside, so the screen side had three Seattle players against two defenders, and the ball was gone before the fifth rusher mattered. A pressure call against a screen is the worst outcome for a defense, and it happens because the defense was guessing too: second-and-1 from the shotgun is a coin flip for everyone. Could step 8 have warned you? The charting can’t say whether the Will showed his blitz before the snap, so the reconstruction doesn’t draw a tell. If he did (a linebacker creeping toward the line with one safety deep), your second-layer call should have moved from “four rush” toward “five or more, ball out fast”.

Snap 7: first-and-10 at the Rams’ 20. Back to 12 personnel, under center, two backs, motion, against base and a seven-man box. Every step says run: run, 75%. It was play-action. Darnold faked the handoff, left the pocket and, after 4.7 seconds, found the tight end AJ Barner short on the left for seven. ✗. This is the miss you should have, the tendency-breaker built to beat the reader who believes the look: an offense from the Shanahan tree lives on making its run and play-action looks identical (Play-Action, Bootlegs and Screens). At 75% the call was honest. At 95% it would have been a misread. And the model doesn’t know it’s Seattle; you would. From 12 personnel under center on first down, Seattle threw 42% of the time in 2025 (139 snaps), against 34% for the league, so a reader who copied the team’s tendencies before kickoff would have started this snap several points closer to pass.

Snap 8: second-and-3 at the 13, no huddle. Seattle hurried to the line in the same personnel, under center with one back. The call is run, 70%, and it missed again: a quick throw to Jake Bobo, out 0.6 seconds after the snap and aimed at a spot 1 yard behind the line, which FTN charts as a designed throw with no progression: a perimeter extension of the run game rather than a dropback. Incomplete. The second layer split: in this spot (second-and-short in the red zone, four defensive backs) the 2025 base rates said man (70%) and five or more rushers (80%). The Rams did send five, but played Cover 3 behind them. Two run-call misses in a row, both passes from a run look, is not bad reading; it’s a play-caller deliberately working against his own tendency in the red zone. The between-snaps habit (card line 13) is where you’d note it, and your next call from that look should drop toward 55%.

Snap 9: third-and-3 at the 13, left hash. Shotgun, 11 personnel, motion: the model says pass, 75%. Third-and-short in the red zone is one of the few spots where man coverage is about as common as zone (the 2025 base rate for this spot was man 55%), so the coverage call was close to a coin flip. The Rams played zone, a Cover 6 (quarters to one side, Cover 2 to the other), set the way Cover 6 usually is: quarters to the field, on the right, where Kupp was. They rushed four and got interior pressure, but Darnold threw on time, 2.4 seconds after the snap, to a receiver in his progression rather than a checkdown:18 Kupp on a short post, caught at the 6, six more yards after the catch, touchdown. ✓

Show the Python
def snap9():
    off = offense("gun_2x2", strength="left")      # X wide right, H (Kupp) right slot, Y left slot, Z wide left
    dfn = [Player("WDE", "DE", "def", 1.0, -5.8), Player("WDT", "DT", "def", 1.0, -2.0),
           Player("SDT", "DT", "def", 1.0, 1.0), Player("SDE", "DE", "def", 1.0, 5.8),
           Player("MIKE", "MIKE", "def", 4.0, 1.5), Player("WILL", "WILL", "def", 4.0, -2.5),
           Player("NB", "NB", "def", 4.5, 7.4), Player("LCB", "CB", "def", 5.0, -15.8, label="CB"),
           Player("RCB", "CB", "def", 6.0, 15.8, label="CB"), Player("FS", "FS", "def", 10.0, -8.0),
           Player("SS", "SS", "def", 9.5, 7.0)]
    p = Play(off, dfn, los=97, to_go=3, ball_y="left")
    p.highlight("H")
    p.motion("Y", to=(-1.5, -5.4), speed=5.0)                 # short motion in to a wing
    p.motion("WILL", to=(4.0, -3.4), speed=1.5)               # a bump, not a chase: zone
    # offense
    p.route("H", r.path((4.0, 0.0)), speed=5.0)
    p.route("H", r.path((0.5, 1.2)), speed=5.0)               # a jab outside...
    p.route("H", r.path((4.0, -3.2)), speed=5.0)              # ...then the post, inside the nickel
    p.route("H", r.path((0.6, -1.2)), speed=1.0)              # finds the window in front of the safety
    p.route("H", r.path((6.5, -1.5)), speed=6.5)              # and runs it in
    p.route("X", r.fade(14)); p.route("Z", r.slant(2, 6)); p.route("Y", r.flat(1.5, 6), side=-1)
    p.block("RB", pts=[(0.6, -3.2)])                          # the back helps to the left
    for k in ("LT", "LG", "C", "RT"):
        p.block(k, pts=[(-1.2, 0.0)])
    p.block("RG", pts=[(-1.4, 0.5), (-2.4, 0.4)], speed=1.5)  # beaten inside
    p.dropback(2.5)
    # defense: four rush; quarters to the right, Cover 2 to the left
    for k in ("WDE", "WDT", "SDE"):
        p.rush(k, pts=[(-1.6, 0.0)], speed=2.0)
    p.rush("SDT", pts=[(-2.0, 0.6), (-6.0, -0.2)], speed=2.6)  # through the B gap as the ball leaves
    p.drop("RCB", (12.0, 15.0), speed=4.5)
    p.drop("SS", (12.0, 12.0), speed=3.0)                      # #2 went inside: he helps on X's fade...
    p.path("SS", [(12.2, 9.4)], speed=4.5, delay=0.6, absolute=True)   # ...and comes back too late
    p.path("NB", [(4.6, 11.6)], speed=4.0, absolute=True)      # bites on the jab
    p.path("NB", [(9.5, 6.4)], speed=5.0, delay=0.9, absolute=True)
    p.drop("LCB", (3.0, -13.5), speed=3.0)
    p.drop("FS", (16.0, -8.0), speed=4.0)
    p.drop("WILL", (6.0, -5.5)); p.drop("MIKE", (6.5, 1.0))
    p.pass_to("H", t=2.4)
    return p


s9 = snap9()
animate_or_strip(s9, [0.0, 1.6, 2.45, 4.6],
                 ["Snap · after the motion: nickel, six in the box", "+1.6 s · Kupp jabs outside; the nickel bites",
                  "+2.4 s · ball out as the pressure arrives", "+4.6 s · touchdown"],
                 lateral=(-21, 21), window=(-8.5, 22.0))
Figure 74.12: Snap 9, third-and-3 at the Rams’ 13, from the left hash (a reconstruction, simulated in Big Data Bowl format). Charted facts: 11 personnel in the shotgun with motion; nickel, six in the box; four rushers, with interior pressure (charted: Kobie Turner, a defensive tackle, hit Darnold); Cover 6; the ball thrown inside the progression (not a checkdown) to Cooper Kupp (H, ringed) on a post from the right side, toward the field, seven yards in the air and six after the catch for the touchdown. Illustrative: the alignments, the tight end’s motion (Y), the other routes, the rusher’s path, and the coverage drawn as quarters on Kupp’s side and Cover 2 on the other. The nickel ($) bites on Kupp’s jab outside, and the quarters safety on that side widens to help on the fade outside him and comes back too late. What to notice: the third panel, the ball leaving as the tackle comes through the B gap; and that in the red zone the deep defenders are already near the goal line, so a short post that gets inside the nickel and in front of the safety has room to run.

Add it up. The run-or-pass calls went 6 for 9. The three misses were all passes from looks that lean run: a coin flip on second-and-1, then play-action and a quick throw from under center with two tight ends, the looks an offense uses to spend its tendency. On the 6 dropbacks the second layer went 4 for 6 on the coverage family and 5 for 6 on the rush count. The coverage misses both came in the red zone, where the base rate leaned man and the Rams played zone; the rush miss was the second-and-1 blitz. That’s a realistic live result for a good reader, and it says where to look harder: in the red zone, don’t trust the base rate for coverage, because that’s where the motion response and the shell earn their seconds.

Three habits from the drive are worth keeping:

  • Score the read, not the play. Snap 2 was a correct run call on a run that lost yards; your log grades what you could know before the snap.
  • Expect the tendency-breaker where it is cheapest. Play-action from a heavy look on first down, and a quick pass from the same look in the red zone, are the classic places an offense spends a tendency. A 65% call that misses there is fine; a 90% call that misses there is a lesson.
  • Notice the defense’s bets. Dime with five in the box on third-and-9, a nickel the moment Smith-Njigba came on, five rushers on second-and-1: each is the defense making its own prediction, and each is a clue to the second layer.
NoteGo deeper: the same drive in real tracking data

The figures above are simulations in the Big Data Bowl’s tracking format. Real tracking data is public only for each year’s Big Data Bowl release, and none covers the 2025 playoffs (the 2026 edition used the 2023 regular season). For a play that is in a release, the real version is one call to gridiron.bdb.prepare() away; Tracking Data and the Big Data Bowl walks through it.

from gridiron import bdb, show_animation

trk = bdb.load_tracking("data/bdb/tracking_week_*.csv")     # a Big Data Bowl release, downloaded from Kaggle
plays = pd.read_csv("data/bdb/plays.csv")
one = bdb.prepare(trk, plays, game_id=2022091100, play_id=55)   # any game and play in that release
show_animation(one, times=[-1.0, 0.0, 1.5, 3.0])              # the same video / frame strip as above

Between snaps: keeping the read up to date

The best clue about the next snap is often the last twenty, and two habits keep your read current while the broadcast does something else. After every snap, during the replay, write the log line (card line 12), and don’t skip the clue column: after a few games it tells you which tells are working for you.

At every stoppage (a TV timeout, the end of a quarter, a change of possession), update the picture of this team, today:

  1. What are they doing from each look? “Three runs from 12 personnel under center on first down” is a game tendency. It is more current than any season number, and play-callers know it too, so expect the tendency breaker when the streak gets long: not because a pass is owed, but because the play-caller is watching the same tally you are.
  2. What did they just change? A new protection against a rusher who is winning, a coverage rolled toward a receiver who is hurting them, a run that worked coming back with a twist (In-Game Adjustments and Tempo).
  3. What does the clock want? The score and the time left change the base rate of step 1 (a team ahead late runs, a team behind throws). And when fourth down comes, make it its own prediction before the offense decides: go, kick or punt, with a confidence, logged in the second-layer columns (l2 go, l2conf 0.6).

When the offense goes fast, or the camera won’t show you

Against tempo the twenty seconds shrink to five or ten, and the full checklist won’t fit. Run the short one: 1, 3, 6, 9. Situation (you already have it from the last snap, plus the new down and distance), formation (the single biggest clue after the situation), shell (the single most useful second-layer clue), and the call. Drop personnel (it usually hasn’t changed; that’s the point of tempo), motion (tempo offenses use less of it), and the box and leverage counts you can’t finish anyway. The measured value of what you drop is small for run or pass; the cost is mostly in your second layer, so lower those confidences when the offense is hurrying.

The same applies when the broadcast is still on a replay at the snap, which happens more than it should. Call it from the situation alone, write “sit” as the clue, and move on. A 60% call from the scorebug is still a call.

The commonest problem is milder: the snap is live, but the shot is tight, and the safeties and corners are off the top and sides of the screen. Steps 1 to 5 still work (the formation and the box are in the picture); steps 6 and 7 don’t. Write ? in the shell column rather than guessing, make your second-layer call from the base rate alone, and keep its confidence at 60% or below. Then use the replay: the end-zone and high angles usually show the whole secondary, so fill in the shell afterwards and note that you saw it late. Coverage calls you couldn’t see live get checked after the game, on the All-22, as Film Study shows.

The log, final form

Your log has grown a column at a time since Part 1. Here is its final form, the one Film Study will score. Keep the columns from How to Watch a Broadcast (snap, quarter, clock, down, to-go, ball on, score) and add or tighten these:

Column What you write Example
pers Offensive personnel, two digits 12
align QB alignment and number of backs U1 (under center, one back), S0 (empty)
motion Motion or shift before the snap M or blank
box Defenders in the box, if you counted 7
shell One-high or two-high, pre-snap; ? if it was off screen 1, 2 or ?
call Run or pass R
conf Your stated confidence, as a decimal 0.65
clue The one thing that decided it align
l2 Your second-layer call (on fourth down: go, kick or punt) zone, 5+, left, go
l2conf Its confidence 0.6
result, l2result What happened, filled in after the snap P, zone

At halftime, a few lines of pandas tell you how the half went. Here it is run on the drive above, typed into a CSV file called nfc_title_q3.csv with these nine lines (copy them if you want to run it yourself):

snap,down,togo,pers,align,call,conf,clue,result
1,1,10,12,U1,R,0.55,align,R
2,2,7,12,U2,R,0.6,align,R
3,3,9,11,S1,P,0.9,align,P
4,1,10,13,U2,R,0.7,align,R
5,1,10,11,S0,P,0.85,align,P
6,2,1,11,S1,R,0.55,align,P
7,1,10,12,U2,R,0.75,align,P
8,2,3,12,U1,R,0.7,align,P
9,3,3,11,S1,P,0.75,align,P
import pandas as pd

log = pd.read_csv("nfc_title_q3.csv")
log["hit"] = log["call"] == log["result"]
print(f"Right on {log.hit.sum()} of {len(log)} calls ({log.hit.mean():.0%}); average stated confidence {log.conf.mean():.0%}")
bands = pd.cut(log.conf, [0.5, 0.6, 0.7, 0.8, 0.91], right=False, labels=["50-59", "60-69", "70-79", "80-90"])
print(log.groupby(bands, observed=True).hit.agg(calls="size", right="mean").round(2).to_string())
Right on 6 of 9 calls (67%); average stated confidence 71%
       calls  right
conf               
50-59      2    0.5
60-69      1    1.0
70-79      4    0.5
80-90      2    1.0

Two numbers to watch over a season, not a half: your hit rate against your average stated confidence (if you’re right 70% of the time while claiming 80%, you’re overconfident), and the hit rate within each band (your 80% calls should come true about 80% of the time). Nine snaps can’t tell you either; a few hundred can, and that is what the next chapter does with them.

TipWatch for it on Sunday: the live run

For one half of one game, watching one offense (one team’s play-caller, so the between-snaps tally is about one set of habits; switch teams the next time):

  • First, a warm-up. Before kickoff, pause a recorded game the moment an offense sets, give yourself ten seconds, and say steps 1 to 9 out loud. Do it on five snaps. The order should feel automatic before the clock is real.
  • Before every snap, run the card in order: situation, personnel, formation, motion response, box, shell, leverage, pressure, call. Say the call and a confidence between 50% and 90% out loud before the snap. If the broadcast is still on a replay, call it from the situation and write “sit”.
  • On the snaps where you have time, add one second-layer call with its own confidence: on likely passes, the coverage family or the rush (four, or five or more); on likely runs, the direction.
  • During the replay, write the line: down, to-go, pers, align, call, conf, clue, and the second-layer call if you made one. Fill in the result.
  • At each stoppage, update the tally of what this team does from each look today, and note anything it changed.
  • When the offense goes no-huddle, switch to the short checklist (1, 3, 6, 9) and lower your second-layer confidences.
  • At halftime, score it: hit rate, average confidence, and the hit rate of your 80%-plus calls. Keep the sheet. Film Study adds a second half of work to it: the All-22, the chart, and the score.

Predict the play

TipPredict the play 1: first down, two backs
Show the Python
off = offense("i_form")
dfn = defense("4-3_over", "single_high", off)
dfn = [p_.moved(d=6.5, w=7.4) if p_.key == "SS" else p_ for p_ in dfn]
dfn = [p_.moved(label="CB") if p_.pos == "CB" else p_ for p_ in dfn]
pd1 = Play(off, dfn, los=40, to_go=10)
pd1.highlight("SS")
fld = pd1.draw(lateral=(-21, 21), window=(-9.5, 17.0), title=False)
no_numbers(fld.ax)
scorebug(fld, pd1, ["Q1  12:40", "0 – 0", "1st & 10, own 30"], at=(-7.2, -19.6))
tag(fld, pd1, "8 in the box", (8.8, 4.0), fs=6.8)
plt.show()
Figure 74.13: First quarter, 0–0. First-and-10 at the offense’s own 30. The offense has 21 personnel (two backs, one tight end) in an I-formation: quarterback under center, fullback (F) and tailback (T) stacked behind him, the tight end (Y) on the right. The defense is in base with the strong safety (ringed) walked down to the edge of the box: eight defenders in the box, one safety deep in the middle.

Run or pass, how sure are you? And a second layer: if it’s a run, which way; if it’s a pass, what kind?

Run, about 70%; if it’s a run, lean left, away from the walked-down safety (55%); if it’s a pass, expect play-action. Situation: first-and-10 is a coin flip (50% passes in 2025). Personnel and formation: 21 personnel with the quarterback under center moves it a long way: on first-and-10 from that look, offenses threw only 31% of the time in 2025. Box: eight in the box with one safety deep is the defense loading up against the run, and offenses facing it from this look threw a little less often (27%, though that’s only 77 snaps).

So why not 85 or 90? Because this is the exact picture play-action is built for. One deep safety and eight defenders committed to the run leave the deep middle and the space behind the linebackers thin, and an offense that gets the linebackers to step up for the fake can throw a post or a deep crossing route behind them (Play-Action, Bootlegs and Screens). Which way, if it’s a run? The I-formation hides the direction (with the fullback straight behind the quarterback there’s no offset to read), so the clue is the box. The safety who walked down did it on the tight end’s side, the formation’s strength, and he is the eighth man; an offense that sees him usually runs away from him or checks to a pass. Lean left, to the weak side, but only about 55%: plenty of teams run at the strength anyway, with the tight end and fullback as extra blockers. A good log line: call R, conf 0.70, clue box, l2 “left”, 0.55 (if pass, PA shot).

TipPredict the play 2: the mugs on third down
Show the Python
off = offense("gun_2x2")
dfn = defense("nickel", "two_high", off)
DM2 = {"MIKE": (1.0, 0.9), "WILL": (1.0, -0.9), "FS": (12.0, -9.0), "SS": (12.0, 9.0), "LCB": (7.0, -16.0),
       "RCB": (7.0, 16.0), "WDE": (1.0, -6.6), "WDT": (1.0, -2.6), "SDT": (1.0, 2.6), "SDE": (1.0, 6.6)}
dfn = [p_.moved(d=DM2[p_.key][0], w=DM2[p_.key][1]) if p_.key in DM2 else p_ for p_ in dfn]
dfn = [p_.moved(label="CB") if p_.pos == "CB" else p_ for p_ in dfn]
pd2 = Play(off, dfn, los=68, to_go=6)
pd2.highlight("MIKE", "WILL")
fld = pd2.draw(lateral=(-21, 21), window=(-8.5, 16.5), title=False)
no_numbers(fld.ax)
scorebug(fld, pd2, ["Q2  6:15", "HOME 10 – AWAY 7", "3rd & 6, opp 42"], at=(-6.4, -19.6))
tag(fld, pd2, "two A-gap mugs", (3.6, -4.6), fs=6.8)
plt.show()
Figure 74.14: Third-and-6 at the opponent’s 42, second quarter. Shotgun, 11 personnel, two receivers each side, the back to the quarterback’s left. The defense shows a pressure look: both inside linebackers (ringed) have walked up into the A gaps, either side of the center, so six defenders are at the line. Behind them, two safeties stand 12 yards deep and both corners are seven yards off.

It’s a pass; how many will rush, and where should the ball go?

Pass, near certain; four rushers is still the best single call (about 60%), and the ball should come out fast. Run or pass is the cheap part: on third-and-4 to 6 from the shotgun with 11 personnel, offenses threw 93% of the time in 2025. Spend the rest on step 8.

Count the deep players first. Two safeties at 12 yards and two corners seven yards off are four defenders who are, for now, playing coverage. That leaves seven for the rush and the underneath zones, and six of them are standing at the line. If all six rushed, there would be one defender underneath, which almost no defense risks with the ball at midfield. Six can come only as Cover 0, man-to-man with nobody deep, and for that both safeties have to walk down; if they’re still at 12 when the ball is snapped, six isn’t coming. So the realistic menu is: both mugs bail and the four linemen rush (a bluff, with ordinary coverage behind it); one mug comes while a lineman drops into his spot, still four rushers but from unexpected places (a simulated pressure, the creeper of Zone Blitzes); or one mug comes and one drops, a five-man pressure. On third-and-4 to 6 in 2025, defenses sent five or more on only 27% of dropbacks. A pressure look moves that number up, but probably not past half, so “four rush, 60%” is an honest call, and “five or more, 40%” is the alternative you keep in mind.

Where the ball goes: the offense has to protect against six, so the quarterback will likely set the protection to the mugs and have a quick answer built in. Expect a throw in under 2.5 seconds, near the sticks, to the side a dropping linebacker has the farthest to run to. Which side that is, you can’t know before the snap: it depends on which mug drops, and that’s exactly why the quarterback’s answer is a read he makes after the snap. And if a mug drops into the throwing lane, that is the simulated pressure doing its job.

TipPredict the play 3: tempo after a first down
Show the Python
off = offense("gun_trips")
dfn = defense("4-3_over", "two_high", off)
DM3 = {"SAM": (4.5, 9.2), "MIKE": (4.5, 1.2), "WILL": (4.5, -2.8), "FS": (12.5, -8.5), "SS": (12.5, 9.5),
       "LCB": (7.0, -16.0), "RCB": (7.0, 17.6)}
dfn = [p_.moved(d=DM3[p_.key][0], w=DM3[p_.key][1]) if p_.key in DM3 else p_ for p_ in dfn]
dfn = [p_.moved(label="CB") if p_.pos == "CB" else p_ for p_ in dfn]
pd3 = Play(off, dfn, los=65, to_go=2)
pd3.highlight("SAM")
fld = pd3.draw(lateral=(-21, 21), window=(-8.5, 16.5), title=False)
no_numbers(fld.ax)
scorebug(fld, pd3, ["Q3  9:02", "HOME 17 – AWAY 17", "2nd & 2, opp 45  ·  no huddle"], at=(-6.4, -19.6))
tag(fld, pd3, "base defense, frozen", (9.0, -3.0), fs=6.8)
plt.show()
Figure 74.15: Second-and-2 at the opponent’s 45, third quarter. The offense just ran for 8 yards on first down out of 11 personnel and sprinted to the line without huddling or substituting. Now it shows trips right: the tight end (Y) attached to the right tackle, a slot receiver (H) and Z outside him, with X alone on the left. The defense, which had its base personnel on the field (three linebackers, two corners, two safeties), couldn’t substitute: its strongside linebacker (S, ringed) has had to walk out over H, a receiver, in space.

What is the offense hunting, and what’s your call?

A lean to pass, about 60%, aimed at the linebacker on the slot. Situation: second-and-2 is a run down overall (only 31% passes in 2025), but from the shotgun with 11 personnel it was 57%, and with no huddle 64% (a small sample, 131 snaps, so don’t lean on the decimal). Second-and-short is also the down where offenses take shots, because a failure still leaves third-and-short.

Personnel and tempo: this is the substitution freeze at work. Because the offense didn’t substitute, the defense had no guaranteed chance to swap a linebacker for a nickel back, so its strongside linebacker is now standing over a slot receiver in space. That is the matchup the offense sprinted to the line to get: a quick throw or a crossing route at the linebacker, or a deeper shot if the safeties cheat up for the run.

Why only 60%: walking the Sam out leaves the frozen base defense only six in the box, and with the tight end attached the offense has six blockers for them, a fair fight for a run. A team that just gained eight on the ground may simply run it again. Tempo tells you the offense likes something about this picture; it doesn’t tell you which of the two things it likes. A good log line: call P, conf 0.60, clue tempo, l2 “target the slot”, 0.55.

Takeaways

  • Order beats speed. The master checklist asks nine questions in the order the game answers them (situation, personnel, formation, motion response, box, shell, leverage, pressure, call), and each answer changes what the next clue means.
  • Run or pass is mostly settled by step 3. In 2024–2025 data the situation and the formation did almost all the work; motion, tempo and the box added under a point. Spend the later steps on the second layer: coverage, rush, where the ball goes.
  • Tells are leans with reasons. Every tell in the catalogue leaks because of physics or assignment, and every one is broken on purpose by somebody. Trust them in proportion to how often they’re right, and expect the break where it’s cheapest for the offense.
  • Say a number. A stated confidence between 50% and 90%, made before the snap, turns watching into something you can score. Start from the base rate, move with strong clues, and rarely go past 90.
  • Score the read, not the play. A correct call on a failed play is a correct call, and a 65% call that misses on a tendency-breaker is honest reading.

Connections

Builds on: every “checklist so far” card (Motion and Shifts, Offensive Systems, Disguise and Rotation, Zone Blitzes); the first tells and the prediction log in How to Watch a Broadcast; tempo and in-game adjustment in In-Game Adjustments and Tempo; the clock in The Clock, the Two-Minute Drill, and Fourth Down; base rates and tendencies in Tendencies: PROE, Personnel, Motion, and Coverage.

Sets up: Film Study: All-22, Charting a Game, and Scoring Yourself, which takes the log you keep live, adds the All-22 film and a full chart of the game, and scores your season of stated confidences with a Brier score and a calibration plot.

Case studies: the film-room drive was run by an offense from the Shanahan family (Shanahan’s Wide Zone Tree) against a defense from the two-high era (Fangio’s Two-High Revolution and Macdonald’s Answer).

NoteGo deeper
  • Atul Gawande, The Checklist Manifesto (2009): why short, ordered checklists help experts rather than replace them.
  • Philip Tetlock and Dan Gardner, Superforecasting (2015): stating probabilities, keeping score and updating, the habits behind this chapter’s confidence column.
  • Chris B. Brown, The Art of Smart Football (2015), and his Smart Football site: the pre-snap game from the play-caller’s side.
  • Cody Alexander’s MatchQuarters newsletter and Ted Nguyen’s film breakdowns at The Athletic: coverage and pressure tells explained by people who watch the All-22 every week.
  • The nflverse project (nflreadpy) and FTN’s charting: everything in this chapter’s charts, free to download, and the raw material for your own base rates.

  1. NFL Football Operations, “Football terms” glossary (the 40-second play clock, and the 25-second clock after certain administrative stoppages): https://operations.nfl.com/learn-the-game/nfl-basics/terms-glossary/↩︎

  2. NFL, Official Playing Rules, Rule 5, Section 3, Article 3 (“Speakers in helmets”): coach-to-player communication “is cut off when the play clock reaches 15 seconds or the ball is snapped, whichever occurs first.” Rulebook: https://operations.nfl.com/the-rules/nfl-rulebook/ . The rule and its history are covered in The Pre-Snap Phase.↩︎

  3. Atul Gawande, The Checklist Manifesto: How to Get Things Right (Metropolitan Books, 2009), on aviation checklists, the surgical safety checklist, and “do-confirm” against “read-do” checklists.↩︎

  4. Course calculation from nflverse play-by-play (pass_location, run_location) and FTN charting (starting_hash, is_screen_pass), 2025 regular season, plays snapped from a hash that went left or right. FTN does not say whose left and right it means; the screens are the evidence that it is the offense’s, because a screen is the play most often sent to the wide side, and from FTN’s “L” hash most screens go right. The same check shows that runs from a hash split almost evenly (48% to the field), so the hash alone doesn’t call a run’s direction.↩︎

  5. Course calculation from nflverse play-by-play joined to FTN charting (quarterback alignment, tempo) and participation (personnel), 2025 regular season, runs and dropbacks with a down recorded. “Pass” is nflverse’s pass flag, so sacks and scrambles count as passes, as in your log.↩︎

  6. Daire Carragher, “NFL pre-snap motion usage reaches new highs across the NFL,” PFF (Feb. 23, 2026): https://www.pff.com/news/nfl-pre-snap-motion-usage-reaches-new-highs-across-the-nfl . PFF’s series counts shifts and motion together and is not comparable with FTN’s is_motion flag, which nflverse defines as motion “before or at the time of the snap.”↩︎

  7. NFL.com, “The coverage that turned the Seahawks into Super Bowl winners and is taking the NFL by storm” (Next Gen Stats): https://www.nfl.com/news/the-coverage-that-turned-the-seahawks-into-super-bowl-winners-and-is-taking-the-nfl-by-storm (two-high on 32.9% of dropbacks in 2018, 42.0% in 2025).↩︎

  8. Phil Perry, “Super Bowl 60 preview: Four matchups that will decide Patriots-Seahawks,” NBC Sports Boston (Feb. 8, 2026): https://www.nbcsportsboston.com/nfl/new-england-patriots/super-bowl-2026-preview-matchups-drake-maye-seahawks/766995 (“if we have the ball for 70 snaps in the game, 65 of them are gonna look the same before the ball’s snapped”).↩︎

  9. Course calculation: nested ridge-penalised logistic regressions (numpy) on nflverse play-by-play, FTN charting and participation, trained on 2024 regular-season runs and dropbacks and tested on all 32,813 in 2025. Each step adds its charted columns (the full list is in the chapter’s code). nflverse’s own xpass model, which uses only the situation, calls 71% of the same snaps correctly.↩︎

  10. Philip E. Tetlock and Dan Gardner, Superforecasting: The Art and Science of Prediction (Crown, 2015), on the forecasting tournaments in which forecasters who used fine-grained numerical probabilities, kept score and updated often were more accurate.↩︎

  11. Same model and data as the chart of what each step is worth, using steps 1 to 3 only. Confidence is the model’s probability for whichever side it called.↩︎

  12. Sarah Lichtenstein, Baruch Fischhoff and Lawrence D. Phillips, “Calibration of probabilities: The state of the art to 1980,” in Daniel Kahneman, Paul Slovic and Amos Tversky (eds.), Judgment under Uncertainty: Heuristics and Biases (Cambridge University Press, 1982), pp. 306–334: the review that established overconfidence as the usual pattern in people’s probability judgments. The Brier figures are a course calculation: a call that says c and comes true at rate q scores q(1 − c)² + (1 − q)c² on average, with q set to the model’s top-band accuracy.↩︎

  13. Course calculation from nflverse participation (defense_man_zone_type) and FTN charting (n_pass_rushers), 2025 regular-season dropbacks with a rush count recorded; coverage share among those charted man or zone. On the season-to-season jumps in FTN’s man share (28.8% in 2022 under the earlier NGS source, then 41.2%, 48.3% and 30.8% for 2023–2025), see the course’s notes on the 2022/2023 source break in Tendencies.↩︎

  14. Score, date and site from nflverse schedules and play-by-play (game 2025_21_LA_SEA: Seattle 31, Los Angeles Rams 27, January 25, 2026, Lumen Field); also “2025–26 NFL playoffs,” Wikipedia: https://en.wikipedia.org/wiki/2025%E2%80%9326_NFL_playoffs . Seattle’s 14–3 record and NFC top seed, and Super Bowl LX (Seattle 29, New England 13, February 8, 2026): https://en.wikipedia.org/wiki/Super_Bowl_LX .↩︎

  15. Seattle’s 2025 staff as listed for Super Bowl LX: https://en.wikipedia.org/wiki/Super_Bowl_LX . Darnold and Kupp (released by the Rams) both signed with Seattle in March 2025: https://www.si.com/nfl/cooper-kupp-signs-seahawks-rams-release .↩︎

  16. nflverse play-by-play, participation and FTN charting for game 2025_21_LA_SEA, Seattle’s drive beginning at 9:41 of the third quarter. The charting records personnel (including who was on the field), the quarterback’s alignment, backs, motion, tempo, the hash, box count, rushers, coverage label and time to throw, but not exact alignments or the routes of receivers who weren’t thrown to. Charting is done by people and contains errors; the labels are FTN’s, not the teams’.↩︎

  17. Personnel and the players on the field from nflverse participation (offense_players, defense_personnel) for the drive’s nine snaps, with names from nflverse rosters. Smith-Njigba’s award: NFL.com, “List of NFL Honors award winners from 2025 NFL season”: https://amp.nfl.com/news/list-of-nfl-honors-award-winners-from-2025-nfl-season .↩︎

  18. FTN’s read_thrown field (nflverse FTN data dictionary: https://nflreadr.nflverse.com/articles/dictionary_ftn_charting.html) codes this throw as a read in the progression, not a checkdown or a designed throw; which read is ambiguous in the 2025 file, and Film Study shows how to check calls like this one on the All-22.↩︎