Building Comeback Ceiling – live NFL comeback charts – the gritty details

comebackceiling.com answers one question: for any point in an NFL game — any time remaining, any team, with or without the ball — what’s the largest deficit anyone has ever actually come back from?

Getting to a site that answers that question took longer than I expected, and involved resolving source data that had a variety of gaps. This is the story of how it got built — from getting a 9GB folder of downloaded HTML box scores through to analysis and a live site.

Where it started: downloading 13,000+ games

I knew the first thing I needed to do was get data locally that I could query and wrangle – there was no way I knew enough about various edge cases to rely on querying the data each time I needed it. For years I knew my scripting skills were bad enough that it wasn’t worth it to even try. 😂

Last year on a whim I just asked Google Gemini if it could generate a Python script that would download the HTML files with the stats I needed. I was surprised at how quickly I was able to do so; it took a couple rounds of back-and-forth to get the details right, and it took a while because I wanted to be respectful of the sites where I was getting data. But in the end I had a local folder with >9GB of data and over 13,000 individual files!

The first (dumb) schema

I’m not a professional software engineer, so I knew I needed a super-simple schema. So I built something dumb – a table where each column represented each second in the game and each row was an individual game. My reasoning was that it would be very simple to identify the games where there was a comeback, then for each column (aka second of the game) I could query across the rows and calculate the biggest delta. Yes, it was dumb.

The biggest problem I encountered at this point was taking the downloaded HTML files and reliably turning them into rows in the table. There were a lot of gaps in the data which drove sufficient complexity that I put the project to the side for a while.

Turning to Claude

Earlier this summer I was using Claude on something else and decided to re-start this project. I just had Claude check out the directory and evaluate the current state. I was pleasantly surprised at just how quickly it identified the inefficiencies (dumb schema!) and came up with proposals for a different approach.

The key change was a smarter schema. In an NFL game the margin, possession, and number of timeouts only change at discrete moments — just a few dozen scoring/possession/timeout events, not 3,600 per-second states. So instead of a column per second, the key table captured data on intervals.

Each row says “from this second to that second, this was the state of the game.” A query for “what was the score with 90 seconds left” becomes a single range-containment lookup instead of scanning across a specific column. Because game state only changes (at most) a few dozen times a game instead of every second, the whole 13,400-game corpus compressed to reasonable 528,000 rows.

Parsing 13,000+ files

Once the schema existed, the harder problem was actually getting real data into it. Box scores from 1994 onward had a full play-by-play table with clock time, down and distance, and a text description of every play. Older games mostly don’t — just a running score by quarter, no clock, no plays.

(Interestingly, in the year that I had put this project aside, the site had backfilled play-by-play data for the entire 1978-1993 range. After re-scraping the data, I had an even better foundation for analysis.)

The parser itself has to do real archaeology on each page – there were random games that were missing play-by-play data, or had other strange one-off issues that needed to be accounted for. But in the end, 11,352 of the 13,000+ games had genuine play-by-play; the remaining ~2,000 only had the coarser tier flag and had to be excluded from analysis.

Computing the frontier

With clean interval data in place, the actual “frontier” computation was anticlimactic: for each 30 second window of game clock, scan every game, scan every play, and for each team-perspective ask “was this team down by X points, and did they go on to win or tie?” Keep the largest X per bucket.

The whole corpus — 11,352 games — computes in about 10.5 seconds and produces a few thousand precomputed rows, small enough to query directly rather than querying raw timelines on every request. I got confidence in the approach immediately – on the very first run of this code it validated what Google could tell you – the largest comeback in NFL history was 33 points, when the Minnesota Vikings’ came back over Indianapolis in 2022.

Nailing the details

Getting final scores and win/loss outcomes right turned out to be the easy 90%. Getting the timing of every deficit right — exactly which second of the game a team was down by how much — surfaced a series of much subtler bugs. (Which I only found by poking at individual data points I thought were strange – Claude isn’t foolproof!):

  • An off-by-one in period-length detection, where a normal 15:00 kickoff computed as elapsed_second = 1 instead of 0.
  • Missing and sparse timestamps. A handful of games have zero logged play times at all; others have long gaps between timed plays. The first pass defaulted missing times to the start of the game or forward-filled from the last known play — both of which could put a real deficit several minutes away from when it actually happened.
  • A mislabeled quarter-header bug, found by spot-checking a 1982 game against its raw rows: on some pages, the “2nd Quarter” divider is placed one row after that quarter’s first play instead of before it, so the parser attributed a real second-quarter deficit to the literal opening kickoff. This turned out to affect about 1 in 20 games with usable play-by-play. (!)
  • The big one. Poking at games in the final second found bad data. The cause: the last logged play in a game is often a few seconds before the clock actually hits 0:00 so the final interval of almost every game was silently truncated before the true end of regulation. It affected 84% of the entire corpus. (!)  Claude fixed by explicitly extending the final regulation interval to the true end of the period, with regression tests and a full corpus reload to confirm the fix propagated everywhere.

None of these bugs ever changed a final score or a win/loss outcome which is why Claude’s tests and full pipeline runs didn’t catch them. They only changed when in the game a real deficit got attributed – which I identified because the data I wanted to display was a bit verbose.

From data to chart, iteratively

The visualization went through several real iterations. I started by identifying the comeback frontier across all games, then creating separate charts for when the comeback team was the home or away team. Then another set of charts based on if the comeback team had possession at that given time or not.

While for most of the game, 30 second intervals were sufficient, I wanted a second-by-second breakdown for the final two minutes of the game, so included those charts, too. I also wanted to understand the biggest deficit ever in an NFL game (where the team behind couldn’t come back), and added that into the chart.

Finally, once I was happy with the individual charts, I decided to combine them all with simple toggles for the home/away and possession/no-possession conditions, and including the all-time deficit and two-minute charts. (Keen viewers will see that in certain places there are multiple lines on the frontier. I decided that if the frontier line only represented a single game, I wanted to know the next-highest deficit on the frontier and chart that, too.)

After a few styling tweaks, it was ready to ship.

Choosing a name

While building it, I had been referring to it as Win Frontiers, but I was smart enough to recognize that was a crappy name. I asked Claude for help, and it came up with a variety of names, checked them for domain name availability, and I was happy with Comeback Ceiling. It was great to have a thought partner with better taste.

What’s next

At some point I’m hoping to get a live data feed, identify when a game has gone past the Comeback Ceiling, and tweet out to the fans of the team. (And if that team sets a new comeback record, to tweet that out, too!)

Introducing… Bayes Calculator

I’m pleased to announce the launch of Bayes Calculator – a passion project I’ve wanted to build for years. I created it to help people understand and visualize Bayesian statistics and Bayesian inference calculations. Check it out now at bayescalculator.com.

It’s been so long I don’t even remember when I was first introduced to Bayesian statistics, but I believe it was when I got my masters’ in Decision Sciences at the London School of Economics. While frequentist statistics was what I’d always learned growing up (and which is obviously still valuable), learning about Bayesian approaches filled a gap I hadn’t known existed.

Since I finally shut down Seed-DB last year, I’ve finally had some time to build the projects that have been kicking around in my head – this is the first to launch. There are a lot of features I’d like to develop, but in the spirit of “launch early” (and often?), I’m posting about it now, and I welcome any and all feedback. [Contact page or tweet at me.]

(I’d like to thank the newest crop of AI tools, without which it would have taken me easily 100 times as long in order to get this off the ground!)

NYC Marathon 2024 – Better performance through electrolytes

The 2024 NYC marathon was my fourth marathon, where I effectively dropped ten minutes off my personal best. And the reason is simple – I finally realized I needed electrolytes! I’m writing this to share what I’ve learned to help anyone else in the same situation.

Quick background

In my previous three marathons, I ran into the same problem – around mile 20/21, I hit the wall and had to start walking periodically for the rest of the race. It just felt like my body fell apart, and I always chalked it up to “that’s what hitting the wall feels like.” This got increasingly frustrating – even when I really controlled my pace in the first part of the race on a particularly easy (downhill!) course, this still happened.

Luckily, I’d been working online with a coach (HUGE shout out to Matt Day from McMillan) and he pointed out that I needed to think about electrolytes!

(This is where I should point out that I’d only ever drunk water on long runs & races – I avoided any electrolytes because I never wanted to drink something that I hadn’t used in training. Nothing new on race day!)

Fueling with carbs – what I was already doing

In the course of training for previous races, I’d already figured out how to get the carbs I needed. I learned to properly eat / carb load in the day or two before long runs (say over ten miles). I also learned about running gels and got into the habit of ingesting a gel about every 30 minutes in a long run or race (starting about an hour in). This approach worked. [Runner’s World article detailing this]

Fueling with electrolytes – the missing piece of the puzzle

My coach Matt pointed me in the direction of a free online calculator to help me understand what I needed – check it out here. My results:

With my existing gels, I was 44g of carbs per hour already – a good chunk of what I needed. And I had been fine with overall fluid intake – that 15 oz/hour was probably what I was getting at the water stations.

HOWEVER! The gels I had been were only giving me ~100 mg of sodium per hour – just 20% of what I needed. (1000mg/32oz = 500mg/16oz, so ~500mg/hour)

The NYC Marathon offers Gatorade Endurance Formula Lemon Lime drinks at aid stations. When I looked up the key stats, I saw that this provides (per 12 oz):

  • 300mg of sodium
  • 22g of carbs

Additionally, I found alternate gels – Gu Roctane Energy gels – that would provide 250 mg/hour of sodium (versus the 100 mg/hour from my previous gets).

Putting this together, if I drank about the same amount (roughly 12-15 oz of fluids per hour spread across various aid stations) and used the Roctane Energy gels, I would be taking in:

  • 66 g of carbs per hour (44 from gels, 22 from Gatorade)
  • 550 mg of sodium per hour (250 from gels, 300 from Gatorade)

Results – the 2024 NYC Marathon

For the first time ever, I ran an entire marathon without needing to walk a step!

I’ll confess as I got to miles 20 and 21 in the Bronx, I was pretty anxious – this was the point in my previous marathons (and training runs!) where I had previously hit that wall. But as I got through those miles and crossed into Manhattan I got increasingly confident that I’d solved this problem and was ready to go. (Seeing my family cheer me on around mile 22 was also a huge mood lift!)

Instead of falling apart on Fifth Avenue as the hill rises along Central Park, I felt strong! I was energized as I passed loads of people, including those other runners that were forced to walk. Those final miles in Central Park to the finish were electric – I felt strong and the race photos with some of my biggest smiles are along this stretch of the course.

Instead of my body feeling like I was on the ragged edge (despite my legs feeling fine), I ended the race feeling strong… but with very tired legs!

Going forward from here

I’m honestly much more excited to run more marathons in the future – being able to crack this bonking problem is incredibly motivating! Now that I know I can finish strong, I can train with a new purpose. And I know I’m absolutely capable of dropping a lot more time off my new marathon PB/PR.

I’m definitely going to tinker with the specific gels and electrolytes I use, when I take them, etc. Toward the end of the race I was getting annoyed by always taking electrolyte, and could/should have taken water once or twice instead. But I’ve found a solid platform of an approach that will serve me well.

I’m writing this because there are a bunch of resources about getting carbs during the race, I haven’t seen the same about electrolytes. If you feel like you’re fit enough to run a full marathon but have had problems “hitting the wall” harder than you like, I strongly encourage you to use a calculator like I did and figure out if you need to change your approach to electrolytes in your racing.

Clawing back the Senate

The US Senate is a particularly powerful body of the US Congress. The most long-lasting power that the Senate has is to confirm judges to federal courts, including the Supreme Court. If the US has any hope of moderating the current 6-3 very conservative Supreme Court that has started taking existing rights away from American citizens than it’s critical for Democrats to maintain control of the Senate.

The Trump administration did great damage to the judicial system by specifically nominating and confirming a huge number of extraordinarily partisan judges. Only a Democratic-controlled Senate can fix this damage.

The Senate is also unique in that it’s a touch more moderate than members of the House of Representatives. Because Senators are elected state-wide, elections in states that are “purple” (neither far-right nor far-left) can be really competitive.

I’m working to help Democrats maintain and expand their control of the US Senate – and I’d like your help. I’ve created a fundraising page to help channel funds to these critical Senate races and have already raised nearly $300,000. But if you are willing to donate money to the races that have some of the highest policy return for your donation, consider donating today. And if possible set up a recurring donation, which allows the campaigns to plan and make better long-term investments today.

Funds will go to the eight seats with the closest margins that will determine control of the Senate. They are:

Blue states / Democratic incumbents (must HOLD):

Blue states / Republican incumbents (should WIN):

Red states / Republican incumbents (can WIN):

tl;dr

Please donate to some of the most important candidates & races in the 2022 midterm elections. It’s crucial to restore balance to a judicial system that has taken a violent lurch and taken rights away from Americans. We’ve raised nearly $300,000 so far, and hope to hit $500,000 by election day, which is now less than eight weeks away.