Tutorial · 8 min · endpoints verified 2026-09-04

How to build an NFL conditions analytics tool

Roof state on every game, six seasons, 1,693 games with a value. Indoors scores +3.87 more — and the gap has been shrinking every year since 2020.

Why this is hard

The problem with conditions data

Everyone believes weather moves NFL totals and almost nobody measures it, because the measurement needs two things joined: the conditions a game was played in, and the score it produced. Conditions live in weather APIs keyed by latitude and time; scores live in sports APIs keyed by game. Joining them is most of the work, and the join is where the errors get in.

  • A weather API gives you a forecast for a stadium, not a result for a game.
  • Roof state is the largest and cleanest conditions effect, and it is the one most often left out.
  • A pooled multi-season average can hide a trend that reverses the practical answer.

Path 1 · recommended

Have your AI agent build it

The conditions fields are not exposed through an MCP tool — the tools cover matches, standings, player stats, Elo and coverage, none of which carries roof. An agent can scaffold the season and scores; the roof join is a REST call.

1. Connect the MCP server

Add the server in your MCP client and sign in with your Big Balls account. Your client registers itself and handles the token exchange — there is no key to copy.

MCP client configjson
{
  "mcpServers": {
    "bigballs-sports-data": {
      "url": "https://mcp.bigballsdata.com/mcp"
    }
  }
}

2. Tools your agent gets

  • get_matches

    Live, upcoming or historical matches for a sport or league.

  • get_coverage

    Machine-readable map of what we hold and what we do not.

3. Ask for what you want

Paste this at your agent. It is written to make the model check coverage before it designs anything, which is what stops it inventing a field we do not serve.

Prompttext
Using the Big Balls Sports MCP server, list the NFL games for the 2025 season with their final scores, then tell me the average combined points.

What the agent cannot reach

The agent gets scores but not roof, so it cannot split them by stadium type. That split is the whole tool, and it needs the REST endpoint below.

Path 2 · hand-coded

Build it yourself

One endpoint, one grouping. The entire analysis is a single call per season plus an average — the difficulty is in the bucketing, not the code.

  1. 01

    Pull a season with roof state attached

    Every game row carries stadium, roof and surface alongside the scores, so there is no second call and no geocoding. Rest days come along too, which is the other conditions-adjacent variable worth having.

    One season of gamesbash
    curl -s -H "x-api-key: $BBS_API_KEY" \
      "https://api.bigballsdata.com/v1/nfl/games?season=2025"

    Real response, trimmed

    jsonjson
    {
      "data": [
        {
          "game_id": "2025_01_DAL_PHI",
          "season": 2025,
          "week": 1,
          "game_date": "2025-09-04",
          "game_type": "REG",
          "home_team": "PHI",
          "away_team": "DAL",
          "home_score": 24,
          "away_score": 20,
          "stadium": "Lincoln Financial Field",
          "roof": "outdoors",
          "surface": "grass",
          "home_rest": 7,
          "away_rest": 7
        }
      ]
    }
  2. 02

    Bucket the roof values — this is the step that decides your answer

    There are four values and only two buckets. `dome` and `closed` are both roof-shut; `outdoors` and `open` are both roof-open, `open` being a retractable stadium with the roof back. Putting `open` with `dome` because both sound like stadiums with roofs is the mistake that changes the number.

    The bucketingts
    type Roof = 'dome' | 'closed' | 'outdoors' | 'open';
    
    // closed  = retractable roof SHUT      -> plays like a dome
    // open    = retractable roof BACK      -> plays like outdoors
    const indoor = (roof: Roof) => roof === 'dome' || roof === 'closed';
  3. 03

    Split the scoring, then look at it per season

    The pooled gap is +3.87 over six seasons. Print it per season as well: the trend is the finding, and a single pooled number would have hidden it.

    conditions.tsts
    const KEY = process.env.BBS_API_KEY!;
    const api = async (p: string) =>
      (await fetch(`https://api.bigballsdata.com${p}`, { headers: { 'x-api-key': KEY } })).json();
    
    type Game = { season: number; roof: string | null; home_score: number | null; away_score: number | null };
    const indoor = (r: string | null) => r === 'dome' || r === 'closed';
    
    const seasons = [2020, 2021, 2022, 2023, 2024, 2025];
    const all: Game[] = (
      await Promise.all(seasons.map((y) => api(`/v1/nfl/games?season=${y}`)))
    ).flatMap((r) => r.data as Game[]);
    
    // A null score is an unplayed game; a null roof is a stadium we have no value
    // for. Dropping them is not the same as counting them as zero.
    const played = all.filter((g) => g.home_score !== null && g.away_score !== null && g.roof !== null);
    const total = (g: Game) => g.home_score! + g.away_score!;
    const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
    
    for (const y of seasons) {
      const s = played.filter((g) => g.season === y);
      const i = s.filter((g) => indoor(g.roof)).map(total);
      const o = s.filter((g) => !indoor(g.roof)).map(total);
      console.log(`${y}  indoor ${i.length.toString().padStart(3)} @ ${mean(i).toFixed(1)}   outdoor ${o.length.toString().padStart(4)} @ ${mean(o).toFixed(1)}   gap ${(mean(i) - mean(o)).toFixed(2)}`);
    }
    
    const i = played.filter((g) => indoor(g.roof)).map(total);
    const o = played.filter((g) => !indoor(g.roof)).map(total);
    console.log(`\nPOOLED  indoor ${i.length} @ ${mean(i).toFixed(2)}   outdoor ${o.length} @ ${mean(o).toFixed(2)}   gap ${(mean(i) - mean(o)).toFixed(2)}`);

    Real response, trimmed

    jsonjson
    2020  indoor  93 @ 53.5   outdoor  176 @ 47.4   gap 6.12
    2021  indoor  83 @ 48.7   outdoor  202 @ 45.0   gap 3.76
    2022  indoor  86 @ 47.7   outdoor  198 @ 42.5   gap 5.21
    2023  indoor  86 @ 46.6   outdoor  199 @ 42.6   gap 4.05
    2024  indoor  98 @ 47.4   outdoor  187 @ 45.3   gap 2.18
    2025  indoor  92 @ 46.9   outdoor  193 @ 45.5   gap 1.39
    
    POOLED  indoor 538 @ 48.50   outdoor 1155 @ 44.63   gap 3.87

Reference

Every endpoint this tutorial uses

All on the gateway at api.bigballsdata.com. Verified against production on 2026-09-04.

MethodPathReturnsWhy you need itPlan
GET/v1/nfl/games?season=game_id, season, week, game_date, game_type, teams, scores, stadium, roof, surface, home_rest, away_restConditions and result on the same row, which is the join this analysis would otherwise have to make itself. Roof is populated on 1,693 completed games across 2020-2025.Free

Pricing, honestly

Where the free tier stops

Six seasons of games is a small number of calls, so this build is cheap either way. The paid tier is about the archive rather than the volume: a one-season split has an n of roughly 285 and a confidence interval wide enough to swallow the effect.

Free key

jsonjson
The most recent completed season, 1,000 requests a day.

Solo

jsonjson
All six seasons — the 1,693-game sample the pooled figure is computed on.
  • 2025 alone gives a +1.39 gap on 285 games. Six seasons give +3.87 on 1,693.
  • The per-season trend is only visible with the archive; one season is a point, not a direction.
  • Six calls, one per season, and the result caches forever because completed games do not change.

More tutorials

Other build guides

Known gaps

What is not here yet

This is a conditions tool with one conditions variable. The others are not degraded or partial — they are absent, and the difference matters when you are deciding whether to build on this.

  • There is no wind and no temperature. The match_conditions table exists and holds zero rows; the fields are pending a weather licence, not pending an ingest. /v1/matches/:id/weather is a live forecast route that needs a geocoded venue, and NFL matches carry no venue_id, so it does not answer for the NFL either.
  • Roof is a proxy for weather, not a measurement of it. An indoor game is climate-controlled; an outdoor game in a dome-less stadium in September and the same stadium in December are one bucket here.
  • The gap is shrinking and this page does not explain why. 6.12 in 2020 to 1.39 in 2025 is a real trend in the data, and attributing it — rule changes, kicking, scheme, which stadiums opened — is beyond what these rows can support.
  • No surface analysis is offered even though the surface field is right there. Turf-versus-grass needs injury data joined to it to be interesting, and the injury side is not usable for the NFL today.

Questions

Is +3.87 points a real effect or a small-sample artefact?
It is computed on 1,693 completed games, 538 indoor and 1,155 outdoor, which is a large sample for a league that plays about 285 games a season. The more useful caveat is not sample size but drift: the per-season gap runs 6.12, 3.76, 5.21, 4.05, 2.18, 1.39, so the pooled figure averages a declining effect. If you are modelling next week, 2025’s +1.39 is the more relevant number.
Why does the bucketing matter so much?
Because two of the four roof values are retractable stadiums in opposite states. `closed` is a roof-shut game and behaves like a dome; `open` is the same stadium with the roof back and behaves like outdoors. Grouping by "has a roof" rather than "was covered" moves games between buckets and changes the answer. The endpoint gives you the state, not the stadium type, which is the useful thing.
Can I get wind speed or temperature for a game?
No. That is the honest answer rather than a partial one: the conditions table is empty and the fields are held pending a weather licence. There is a forecast endpoint for other sports, but it needs a geocoded venue and NFL matches do not carry one.
Does this cover college football?
Not through this endpoint. /v1/nfl/games is NFL only. NCAAF games are in the unified /v1/matches surface, which does not carry roof.

Build it

Free key, no credit card, 1,000 requests a day. The whole analysis is six calls.