T_CATCH = 3.9 # hang time: the ball is caught at the 2 about 3.9 seconds after the kick
def wait_until(p, key, t):
"""Delay that starts `key`'s next action at absolute time t (gridiron delays are relative).
Note: reaches into gridiron's private Play._compile(); fine for this chapter, fragile if the library changes."""
kf, _, _ = p._compile()
return max(0.0, t - max(0.0, float(kf[key][-1, 0])))
def to(p, key, pts, kind="path", t=None, speed=None):
"""Move `key` through absolute (d, w) points as a path, block or run, optionally starting at time t."""
delay = 0.0 if t is None else wait_until(p, key, t)
if kind == "block":
start = np.array(p._cur(key))
return p.block(key, pts=[tuple(np.array(q) - start) for q in pts], delay=delay, speed=speed)
return {"path": p.path, "run": p.run}[kind](key, pts, absolute=True, delay=delay, speed=speed)
ACTORS = {"B3": "S", "B4": "D", "F3": "D", "KR2": "KR", "K": "K"}
def return_play():
"""A middle-right return against a dynamic kickoff (2026 alignment, legal front line), from kick to tackle.
Ten blockers (nine in the setup zone plus the second returner) for eleven cover men (ten plus the kicker):
the return leaves the backside man (L5) and the kicker unblocked. B3 seals R1 inside, B4 and F3 (both
setup-zone players, so the double team is legal) drive R2 outward, and the second returner, who may not
join a double team, takes R3, the man F3 would otherwise have had, with a kick-out."""
kick, ret = dynamic_units(floaters=(-19, -4, 6, 19), kr=((R(4), 4.0), (R(10), -3.0)), labels=ACTORS, k_d=-1.0)
p = Play(kick, ret, los=KICK_X, to_go=None)
tc = T_CATCH
# the ball is in the air: only the kicker and the returners may move
p.path("K", [(13.0, 0.0)], absolute=True, speed=5.0) # may not cross midfield (d = 15) yet
p.path("KR1", [(R(2), 2.0)], absolute=True, speed=1.0) # drifts under the kick
p.path("KR2", [(R(9), -1.0)], absolute=True, speed=1.0)
# caught at t = 3.9: everyone else is released. Cover man -> where he is engaged, his blocker(s) -> where
# they meet him (blockers take a short drop step to set, so most blocks land around their 33-34),
# then the pair drifts by (dd, dw).
eng = {
"L4": ((31.0, -18.4), [("B1", (32.2, -18.9))], (0.3, 0.0)),
"L3": ((31.0, -13.2), [("F1", (32.2, -13.5))], (0.3, 0.0)),
"L2": ((31.0, -8.4), [("B2", (32.2, -8.7))], (0.3, 0.0)),
"L1": ((31.0, -3.4), [("F2", (32.2, -3.7))], (0.3, 0.0)),
"R1": ((31.0, 1.6), [("B3", (31.8, 2.7))], (0.2, -0.9)), # sealed inside
"R2": ((31.2, 8.6), [("B4", (32.3, 9.6)), ("F3", (32.4, 7.4))], (0.2, 0.6)), # doubled, driven out
"R4": ((31.0, 18.2), [("F4", (32.2, 18.5))], (0.3, 0.0)),
"R5": ((31.0, 22.4), [("B5", (32.2, 22.2))], (0.3, 0.0)),
}
for c, ((d, w), blockers, (dd, dw)) in eng.items():
to(p, c, [(d, w)], t=tc, speed=5.5)
to(p, c, [(d + dd, w + dw)], speed=0.5)
for bk, (bd, bw) in blockers:
to(p, bk, [(bd, bw)], kind="block", t=tc + 0.15, speed=3.0)
to(p, bk, [(bd + dd, bw + dw)], kind="block", speed=0.5)
# R3 reads the return and works toward the lane; the second returner leads up and kicks him out
to(p, "R3", [(29.6, 13.6)], t=tc, speed=5.5)
to(p, "R3", [(31.4, 12.9)], speed=0.9)
to(p, "R3", [(31.7, 14.7)], speed=1.2)
to(p, "KR2", [(R(17), 5.5), (32.5, 12.0)], kind="block", t=tc - 0.4, speed=7.0)
to(p, "KR2", [(32.8, 13.8)], kind="block", speed=1.2)
# the two unblocked men: L5 pursues flat from the backside, staying behind the ball; the kicker is the
# returner's man, waiting past the line of blocks
to(p, "L5", [(27.0, -9.0), (27.8, 2.4)], t=tc, speed=5.2)
to(p, "K", [(25.0, 3.0)], t=tc + 0.1, speed=4.2)
to(p, "K", [(27.6, 4.4)], speed=2.0)
# the returner: up through the lane between the seal (S) and the double team (D + D), tackled past it
to(p, "KR1", [(R(14), 3.8), (R(26), 4.8), (32.0, 5.0), (29.5, 5.0), (27.8, 4.6)], kind="run", t=tc, speed=8.2)
return p
def kick_tracking(p, t_catch=T_CATCH, catcher="KR1"):
"""Simulated tracking (BDB format) with the kick's flight added. gridiron's ball follows a snap, so the
ball rows are rebuilt: tee -> catch along a straight ground track, then in the returner's arms."""
df = p.to_tracking()
df["_id"] = df["displayName"].astype(str)
catch = p.position(catcher, t_catch)
b = df["side"] == "ball"
for i in df.index[b]:
t = df.at[i, "time"]
if t <= 0:
d, w = 0.0, 0.0
elif t < t_catch:
d, w = catch * (t / t_catch)
else:
d, w = p.position(catcher, t)
df.at[i, "x"], df.at[i, "y"] = p.to_abs(d, w)
df.loc[b, "event"] = None
df.loc[b & np.isclose(df["time"], 0.0), "event"] = "kickoff"
df.loc[b & np.isclose(df["time"], round(t_catch, 1)), "event"] = "kick_received"
return df
MARK_R = 0.95
TRAILED = {"KR1", "KR2", "K", "L5", "R3"} # the only players whose paths are drawn: the story's movers
def panel(ax, p, df, t, window, lateral=None, rings=(("KR1", style.THIRD),), tags=(), zoom_box=None,
path_ahead=None, side_labels=()):
"""One moment of the return: markers, the movers' trails, the double team outlined, the kick's remaining
flight, labels with leader lines to the players they name."""
tmap = df.groupby("frameId")["time"].first()
fid = int((tmap - t).abs().idxmin())
lat = None if lateral is None else tuple(sorted((p.ball_y - lateral[0], p.ball_y - lateral[1])))
fld = fix_field(Field(ax=ax, orient="vertical", los=p.los, window=window, numbers=True, hashes=False,
to_go=None, lateral=lat))
lims = (ax.get_xlim(), ax.get_ylim())
band(fld, p, R(35), R(30), style.DEFENSE, 0.08)
band(fld, p, R(20), R(0), style.LINE_TO_GAIN, 0.10)
x0, x1 = p.los + window[0], p.los + window[1]
cur = df[df["frameId"] == fid]
shown = cur[cur["x"].between(x0 + 0.6, x1 - 0.6)]
if lat is not None:
shown = shown[shown["y"].between(lat[0] + 0.6, lat[1] - 0.6)]
ids = set(shown["_id"]) & TRAILED
trails = df[(df["frameId"] <= fid) & df["_id"].isin(ids) & (df["side"] != "ball") & df["x"].between(x0, x1)]
for (_, side), g in trails.groupby(["_id", "side"]):
xs, ys = fld.xy(g["x"].values, g["y"].values)
ax.plot(xs, ys, color=style.OFFENSE if side == "off" else style.DEFENSE, lw=1.0, alpha=0.55, zorder=3)
if t > T_CATCH + 0.8: # the double team, outlined
r = cur[cur["displayName"] == "R2"].iloc[0]
x, y = fld.xy(r["x"] + 0.6, r["y"])
ax.add_patch(Ellipse((float(x), float(y)), 5.6, 4.0, fc=GREEN, alpha=0.20, ec="none", zorder=2.5))
ax.add_patch(Ellipse((float(x), float(y)), 5.6, 4.0, fc="none", ec=GREEN, lw=1.8, ls=(0, (3, 1.5)),
zorder=7.5))
if path_ahead: # where the returner is about to go, drawn as an arrow from where he is
r = cur[cur["displayName"] == "KR1"].iloc[0]
pts = [P_(fld, p, *q) for q in path_ahead]
xa, ya = fld.xy(r["x"], r["y"])
xs_ = [float(xa)] + [q[0] for q in pts]
ys_ = [float(ya)] + [q[1] for q in pts]
ax.plot(xs_[:-1], ys_[:-1], color=style.THIRD, lw=1.4, ls=(0, (3, 2)), zorder=6)
ax.add_patch(FancyArrowPatch((xs_[-2], ys_[-2]), (xs_[-1], ys_[-1]), arrowstyle="-|>", mutation_scale=9,
color=style.THIRD, lw=1.4, ls=(0, (3, 2)), zorder=6, shrinkA=0, shrinkB=0))
ppy = fld.pts_per_yard()
for _, r in shown.iterrows():
x, y = (float(v) for v in fld.xy(r["x"], r["y"]))
if r["side"] == "ball":
if t < T_CATCH - 0.05:
ax.add_patch(Ellipse((x, y), 1.3, 0.8, fc=style.BALL, ec="white", lw=0.6, zorder=11))
continue
lab = str(r.get("label", "") or "")
if r["side"] == "off":
ax.add_patch(Circle((x, y), MARK_R, fc=style.OFFENSE, ec="white", lw=0.6, zorder=8))
tcol = "white"
else:
ax.add_patch(RegularPolygon((x, y), 4, radius=MARK_R * 1.3, orientation=np.pi / 4,
fc=style.DEFENSE_TINT, ec=style.DEFENSE, lw=1.0, zorder=8))
tcol = style.INK
if lab:
fs = ppy * (0.62 if len(lab) <= 1 else 0.5)
ax.text(x, y, lab, ha="center", va="center", fontsize=max(4.0, min(7.0, fs)), color=tcol,
fontweight="bold", zorder=10)
for key, col in rings:
r = cur[cur["displayName"] == key].iloc[0]
x, y = fld.xy(r["x"], r["y"])
ax.add_patch(Circle((float(x), float(y)), MARK_R * 1.7, fc="none", ec=col, lw=1.8, zorder=7))
if t < T_CATCH - 0.05: # the rest of the kick's flight, to the catch
b = cur[cur["side"] == "ball"].iloc[0]
(xa, ya), (xb, yb) = fld.xy(b["x"], b["y"]), P_(fld, p, *p.position("KR1", T_CATCH))
ax.plot([xa, xb], [ya, yb], color=style.BALL, lw=1.1, ls=(0, (2, 2)), zorder=6)
if zoom_box is not None: # where panels 3-4 zoom in
(wd0, wd1), (ww0, ww1) = zoom_box
(xa, ya), (xb, yb) = P_(fld, p, wd0, ww0), P_(fld, p, wd1, ww1)
ax.add_patch(Rectangle((min(xa, xb), min(ya, yb)), abs(xb - xa), abs(yb - ya), fc="none", ec=style.INK_2,
lw=0.9, ls=(0, (3, 2)), zorder=9))
for key in side_labels: # name a player beside his marker, on the kicking team's side of it
r = cur[cur["displayName"] == key].iloc[0]
d, w = float(r["x"]) - p.los, p.ball_y - float(r["y"]) # BDB (x, y) -> (d, w)
say(fld, p, d - 1.9, w, key, color=OC, fontsize=6.0)
for tag in tags:
text, (d, w), col = tag[:3]
targets = tag[3] if len(tag) > 3 else ()
lx, ly = P_(fld, p, d, w)
for tk in targets:
r = cur[cur["displayName"] == tk].iloc[0]
tx_, ty_ = fld.xy(r["x"], r["y"])
ax.plot([lx, float(tx_)], [ly, float(ty_)], color=col, lw=0.7, alpha=0.8, zorder=11)
say(fld, p, d, w, text, color=col, fontsize=6.4)
clip_texts(fld)
ax.set_xlim(*lims[0])
ax.set_ylim(*lims[1])
return fld
FULL = dict(window=(R(57), R(-2)), lateral=None)
ZOOM = dict(window=(R(45), R(15)), lateral=(-15.0, 25.0))
ZOOM_BOX = ((R(45), R(15)), (-15.0, 25.0))
SIDE = [(), (), ("R1", "R2", "R3"), ()]
TIMES = [2.6, T_CATCH, 6.4, 8.2]
NOTES = ["1. Ball in the air: nobody moves\nbut the kicker and the returners.",
"2. Caught at the 2: now everyone\ngoes. Blockers set to meet their men.",
"3. Seal (S) and double team (D, D):\nthe lane opens between them.",
"4. Through the lane; the kicker, the\nreturner's man, tackles him at the 37."]
TAGS = [[("ball in the air", (40.8, 9.0), style.BALL), ("frozen until\nit lands", (R(26), -12.0), style.INK_2),
("kicker: may not\ncross midfield yet", (11.0, -13.0), OC, ("K",))],
[("caught at the 2", (R(6.5), 12.0), DC), ("everyone\nreleased", (R(25), 0.0), style.INK_2),
("zoomed in\npanels 3-4", (R(19.5), 16.5), style.INK_2)],
[("seal: S keeps\nR1 inside", (36.5, -6.5), GREEN, ("B3",)),
("double team\non R2", (36.8, 16.0), GREEN, ("B4",)),
("kick-out:\nKR on R3", (41.5, 19.0), GREEN, ("KR2",)),
("unblocked:\nL5 and K", (23.2, -6.5), OC, ("L5", "K")),
("lane", (35.6, 8.6), style.THIRD)],
[("tackled at\nthe 37", (23.2, 10.5), DC, ("KR1",)),
("kicker: the\nreturner's man", (23.2, -1.5), OC, ("K",)),
("L5, a beat\nlate", (23.2, -10.5), OC, ("L5",)),
("kick-out", (36.5, 19.5), GREEN, ("KR2",))]]
AHEAD = [None, None, [(R(26), 4.8), (32.0, 5.0), (29.0, 5.0)], None]
def return_strip(p, width_in=6.5):
"""Two full-field panels (the kick, the catch), then two zoomed panels (the blocks, the tackle)."""
df = kick_tracking(p)
pw = width_in / 2
h_full = pw * (FULL["window"][1] - FULL["window"][0]) / 53.33
h_zoom = pw * (ZOOM["window"][1] - ZOOM["window"][0]) / (ZOOM["lateral"][1] - ZOOM["lateral"][0])
fig, axes = plt.subplots(2, 2, figsize=(width_in, h_full + h_zoom + 1.25),
gridspec_kw=dict(height_ratios=[h_full, h_zoom]))
for i, (ax, t, n, tg) in enumerate(zip(axes.flat, TIMES, NOTES, TAGS)):
view = FULL if i < 2 else ZOOM
panel(ax, p, df, t, view["window"], view["lateral"], tags=tg, zoom_box=ZOOM_BOX if i == 1 else None,
path_ahead=AHEAD[i], side_labels=SIDE[i])
ax.set_title(f"{t:.1f} s after the kick" + (" (zoomed in)" if i >= 2 else ""), fontsize=8.5,
color=style.INK_2, loc="left")
ax.text(0.5, -0.02, n, transform=ax.transAxes, ha="center", va="top", fontsize=7.5, color=style.INK)
fig.tight_layout(h_pad=2.4, w_pad=0.8)
return fig
def return_video(p, width_in=5.0):
"""Web version: the full-field panel at every frame, captioned with the strip's notes as they happen."""
from matplotlib import animation
from IPython.display import HTML, display
df = kick_tracking(p)
span = FULL["window"][1] - FULL["window"][0]
fig, ax = plt.subplots(figsize=(width_in, width_in * span / 53.33 + 0.4))
tmap = df.groupby("frameId")["time"].first()
frames = [f for f in sorted(df["frameId"].unique()) if tmap[f] >= 0]
def update(fid):
ax.clear()
t = float(tmap[fid])
panel(ax, p, df, t, FULL["window"])
cur = [n for tt, n in zip(TIMES, NOTES) if tt <= t + 1e-6]
ax.set_title(f"{t:.1f} s " + (cur[-1].split(". ", 1)[1].replace("\n", " ") if cur else
"The kick: nobody moves but the kicker and the returners."),
fontsize=8, color=style.INK, loc="left")
return []
anim = animation.FuncAnimation(fig, update, frames=frames, interval=100)
html = (anim.to_html5_video(embed_limit=40).replace("<video ", '<video style="max-width:100%;height:auto" ')
if shutil.which("ffmpeg") else anim.to_jshtml(default_mode="once"))
plt.close(fig)
display(HTML(html))
RETURN = return_play()