Skip to content
FRC Article 20 min read

Building an FRC Scouting App: Data Model, TBA/Statbotics APIs, and Picklists

Build an FRC scouting app: match and pit scouting, a simple data model, pulling schedules and EPA from the TBA and Statbotics APIs, and building a picklist.

to read
20 min

to read

words
2,916

words

sections
7

sections

Every alliance selection comes down to one question: which robots do you actually want next to yours? A good scouting app answers it by turning three days of chaos into a ranked list you can defend. The mistake most teams make is starting with the app — the framework, the dashboard, the color-coded charts — before deciding what data is worth collecting and how it flows from a scout's phone in the stands to the drive team's screen.

This guide builds an FRC scouting system from the data outward: what to record in match and pit scouting, a data model that stays sane all season, how to pull schedules and results from The Blue Alliance and EPA ratings from Statbotics so your scouts never key in what a robot can fetch for free, how to survive a venue with no signal, and how to turn all of it into a picklist. It's stack-agnostic — the same model works for a React web app, a spreadsheet with scripts, or a native mobile build.

What a scouting app actually needs to do

Scouting data comes in two flavors with different shapes, lifetimes, and owners. Match scouting is one observation per robot per match: what did this team do in this match. It's high-volume (a six-team field over ~100 quals is 600+ rows), time-pressured, and collected by a rotating crew in the stands. Pit scouting is one record per team for the whole event — drivetrain, rough dimensions, claimed objectives, a photo — collected once, early, by a couple of people walking the pits.

The third category is the one teams waste the most effort on: objective results that already exist in an API. Final scores, win/loss, rankings, ranking points, and schedules come from The Blue Alliance; summary metrics like EPA come from Statbotics. Never pay a human to hand-copy any of it — scout attention is your scarcest resource, so spend it only on things a computer can't see.

The biggest efficiency win in scouting is subtraction. Before adding a field to your match form, ask: can TBA or Statbotics give me this? If yes, delete it. A scout tracking one fewer counter is one who catches the thing the cameras miss.

Match scouting: what to collect

Keep the form short enough to complete while still watching the match. The template below is deliberately game-neutral — rename the "scoring" fields to match the current season's objectives (always confirm the exact scoring elements against the official game manual, since they change every year).

FieldTypeWhy it's here
match_keystringWhich match — links to TBA and to results
team_keystringWhich robot — the whole point of the row
alliancered / blueCross-check against the TBA schedule
auto_* countsintegerAutonomous actions the API can't attribute per-robot
teleop_* countsintegerPer-robot scoring TBA reports only per-alliance
endgame_statecategoryWhat the robot did at the buzzer
defense_played1–5Subjective, invisible to any API
driver_skill1–5Subjective
broke_downbooleanDid it stop moving / go dead
notestextThe one weird thing worth remembering

Notice what's not here: final score, RP, and win/loss all come from the API. The gold in match scouting is the per-robot breakdown (the API gives per-alliance totals, not who did what) plus the human judgments — defense, reliability, driver skill — no data feed captures. For more on the collection side, see the FRC scouting guide and this scouting sheet template you can adapt into form fields.

Pit scouting: what to collect

Pit data is about capability and constraints — what a robot could do and what might stop it — which match results alone can't show.

FieldExample valuesUse in the picklist
drivetrainswerve, tank, mecanumManeuverability, defense resistance
weight_lbsnumericRobustness, pushing matches
dimensionsLxWxHFit under/through field elements
claimed_scoringlist of objectivesCross-check against what you observe
can_climb / endgameboolean / categoryEndgame planning
cycle_estimatesecondsSanity-check throughput
photo_urlimageRecognize the robot on the field

Treat claimed capability as a hypothesis, not a fact — a team that claims everything and does none of it in quals is telling you something too.

A simple data model

Don't model this like a normalized enterprise database. The winning shape is flat and append-only: one row per observation, no updates, no clever mid-event joins. Flat data is easy to sync, easy to reason about when two scouts disagree, and trivial to aggregate.

-- One row per robot per match, written by one scout. Never updated.
create table match_scouting (
  id            bigserial primary key,
  event_key     text        not null,   -- '2026casf'
  match_key     text        not null,   -- '2026casf_qm12'
  team_key      text        not null,   -- 'frc254'
  alliance      text        not null,   -- 'red' | 'blue'
  scout_name    text,
  -- game-specific objective counts (rename each season):
  auto_scored   int         default 0,
  teleop_scored int         default 0,
  endgame_state text,                   -- categorical
  -- human judgments no API can give you:
  defense       int,                    -- 1-5
  driver_skill  int,                    -- 1-5
  broke_down    boolean     default false,
  notes         text,
  created_at    timestamptz default now()
);

-- One row per team for the whole event.
create table pit_scouting (
  team_key      text primary key,       -- 'frc1678'
  drivetrain    text,
  weight_lbs    numeric,
  claimed       text[],                 -- objectives the team says it does
  can_climb     boolean,
  notes         text,
  photo_url     text
);

Three rules keep this model healthy all season:

  1. Keys are TBA keys. Store frc254, not 254; 2026casf_qm12, not "Quals 12". Sharing TBA's identifiers makes every join to API data free and every event work the same way.
  2. Match scouting is immutable. A submit is a permanent observation; a correction is a new row. Disagreement between rows is signal about scout reliability — don't destroy it with an edit. (A unique (match_key, team_key, scout_name) constraint still blocks the double-submit when a phone retries on flaky signal.)
  3. Objective results live in their own tables, hydrated from the API. Don't mix scouted counts and API scores in one row — keep a matches table you refresh from TBA and join at read time.

Pulling schedules and results from The Blue Alliance

The Blue Alliance (TBA) is the system of record for FRC event data: schedules, team lists, match results, rankings, and awards. Its public API v3 is how your app gets all of it without a human typing anything. The base URL is https://www.thebluealliance.com/api/v3, and every request carries a read key in the X-TBA-Auth-Key header — generate one in the Read API Keys section of your TBA account dashboard. (For a broader tour of TBA, see the Blue Alliance guide.)

The handful of endpoints a scouting app actually needs:

EndpointReturns
GET /event/{event_key}/matchesFull match list: schedule + results
GET /event/{event_key}/teamsEvery team at the event
GET /event/{event_key}/rankingsLive rankings, record, RP
GET /event/{event_key}/oprsOPR / DPR / CCWM per team
GET /team/{team_key}/events/{year}A team's season schedule

Event keys look like 2026casf (year + code); team keys are frc + number (frc254); match keys combine both, e.g. 2026casf_qm12 (qm = qualification, sf/f for playoffs). Pulling a match schedule is a single call:

const TBA = "https://www.thebluealliance.com/api/v3";

async function getMatches(eventKey) {
  const res = await fetch(`${TBA}/event/${eventKey}/matches`, {
    headers: { "X-TBA-Auth-Key": process.env.TBA_KEY },
  });
  if (!res.ok) throw new Error(`TBA ${res.status}`);
  return res.json();
}

Each match object gives you comp_level, match_number, winning_alliance, and an alliances object split into red and blue. Each side carries team_keys (an array of the three robots), score, plus surrogate_team_keys and dq_team_keys — exactly the structure you need to build a scouting queue. Filter to comp_level === "qm", read each side's three team_keys, and assign those six robots to scouts for that match_number.

Two things worth knowing. First, score is -1 before a match is played, so schedule and results pulls use the same endpoint — just re-fetch as the event progresses. Second, TBA returns an ETag on every response; send it back as If-None-Match and you'll get a cheap 304 Not Modified when nothing changed. Cache aggressively — you'll poll the same event all weekend, and TBA asks that you don't hammer it.

Per-robot scoring is not in the TBA match object — the score_breakdown field is per-alliance and its contents change every season. That gap between "the alliance scored 88" and "which of the three robots scored it" is precisely the gap your match scouts exist to fill.

Adding EPA from Statbotics

TBA tells you what happened; Statbotics tells you how good a team is at making it happen. Its headline metric is EPA (Expected Points Added) — a single rating, in the scale of the current game's points, for a team's average scoring contribution to a match. It's the modern successor to OPR-style metrics and a useful prior on team strength, especially early in an event. (For what EPA means, see what Statbotics EPA is; for the linear-algebra metrics TBA still serves, see OPR, DPR, and CCWM explained.)

Statbotics offers a public REST API at https://api.statbotics.io/v3 (no key required) and an official Python package. The Python route is the quickest way to hydrate ratings for a whole event:

import statbotics

sb = statbotics.Statbotics()

team = sb.get_team(254)
print(team["norm_epa"])   # {'current': ..., 'recent': ..., 'mean': ..., 'max': ...}

# EPA for one (team, event) pair:
te = sb.get_team_event(254, "2026casf")

# ...or every team at an event in one call:
rows = sb.get_team_events(event="2026casf")

get_team_events accepts filters for team, year, event, country, state, district, event type and week, plus metric/ascending/limit/offset for sorting and paging. Each team-event record includes an epa object with the team's rating and a per-component breakdown. Because that breakdown maps to the current game's scoring elements, its field names change every season — read the live schema in the Statbotics REST docs rather than hardcoding paths you'll rewrite next year.

EPA is a prior, not a verdict. It's built from the same public results TBA has, so it can't see the robot that scores fast but dies every third match, or the one whose "low" rating is really a great defender. Use EPA to seed your rankings, then let your own scouting move teams up and down.

Designing for offline

The assumption that breaks more scouting apps than any bug: the venue has no internet. Arenas are steel boxes packed with phones; cell service crawls and the event Wi-Fi, if any, is for the FMS, not for you. An app that needs a live connection to submit loses a match's worth of data every time the signal drops.

Build local-first. The scout's device must record a full match, store it, and show its history with the network completely off. Syncing is a separate, best-effort background job. A few patterns, simplest to most robust:

StrategyHow it worksTrade-off
QR handoffEach phone encodes its entries as a QR code; a laptop scans them inZero infra, works anywhere; manual, one device at a time
Local storage + syncWrite to IndexedDB / SQLite; flush to server when onlineSmooth UX; needs conflict handling on resubmit
Offline PWAInstallable web app with a service worker + local DBOne codebase, no app store; service-worker learning curve
Peer syncDevices share over Bluetooth / local networkNo internet at all; fiddly, platform-specific

QR handoff is almost impossible to break: each phone renders its unsynced rows as a QR code (or a short sequence), a runner walks a laptop down the row scanning each phone, and the laptop is your source of truth. No server, no accounts, no signal. Whatever you pick, make "submit" write locally first and mark the row unsynced; a green check when it reaches the server is a bonus, never a requirement.

Turning data into a picklist

Raw rows don't win alliance selection — a ranked list does. The job is to collapse many observations per team into a few numbers your strategy leads can argue over, then order teams by what your alliance needs.

Start by aggregating match scouting per team. Averages are obvious, but consistency matters as much as the mean — a robot that scores 40 every match often beats one that swings between 10 and 70, because you can plan around it:

select
  team_key,
  count(*)                            as matches,
  round(avg(teleop_scored), 1)        as avg_teleop,
  round(stddev_pop(teleop_scored), 1) as swing,      -- lower = steadier
  round(avg(defense), 1)              as avg_defense,
  avg((broke_down)::int)              as breakdown_rate
from match_scouting
where event_key = '2026casf'
group by team_key
order by avg_teleop desc;

Then combine the subjective (scouted) and objective (EPA/OPR) views into one score. A transparent weighted sum beats a black box: at the table you must explain why team A ranks above team B, and a formula your strategy lead helped tune is one they'll trust:

def pick_score(t, w):
    return (
        w["epa"]       * t["epa_norm"]       # prior strength (Statbotics)
      + w["teleop"]    * t["avg_teleop"]     # observed scoring
      + w["defense"]   * t["avg_defense"]    # scouted judgment
      - w["swing"]     * t["swing"]          # penalize inconsistency
      - w["breakdown"] * t["breakdown_rate"] # penalize dying
    )

The weights aren't a math problem — they're a strategy decision that shifts with the game and with what your robot lacks (a strong scorer weights defense and reliability; one that can't reach the endgame weights climb heavily). Keep three practical lists, not one grand ranking:

  • First-pick list — the best complements to your robot, ranked. These are teams you'd captain an alliance around.
  • Second-pick list — teams that fill a specific hole (defense, a fast cycle, a reliable endgame) even if their overall numbers are lower.
  • Do-not-pick list — teams that break down, play dirty, or can't drive. This list quietly saves more alliances than the first-pick list wins.

Feed pit data in as a filter, not a score: if a team claims a climb you've never seen, flag it; if a robot is too wide for a field element, that's a hard constraint no average surfaces. And keep the human in the loop — the app ranks, the drive team decides. The best scouting system still loses if the person at the table can't read it under pressure. If you're building a program from scratch, the broader LearnFRC guides cover strategy and drive-team workflow alongside the tech.

Frequently asked questions

What's the difference between match scouting and pit scouting?

Match scouting is one observation per robot per match — high-volume, in-the-stands data about what a team actually does on the field. Pit scouting is one record per team for the whole event — capability and specs (drivetrain, dimensions, claimed abilities) gathered once from the pits. You want both: pit data tells you what a robot could do, match data tells you what it did.

Do I need an API key for The Blue Alliance and Statbotics?

TBA requires a free read key, passed in the X-TBA-Auth-Key header, which you generate on your TBA account dashboard. The Statbotics v3 REST API is public and needs no key. Both are free for team use — just cache responses (TBA supports ETag / If-None-Match) instead of polling in a tight loop.

Should I store final scores and rankings in my scouting database?

No — pull them from TBA at read time and keep them in their own tables, separate from scouted rows. Final scores, win/loss, and rankings are authoritative from the API, so hand-collecting or duplicating them just creates data that can drift out of sync. Spend scout attention only on per-robot and subjective data the API can't provide.

How do I handle no internet at the competition venue?

Build local-first: record and store every entry on the device with the network off, and treat syncing as a separate best-effort step. QR-code handoff (each phone shows its data as a code that a central laptop scans) is the most bulletproof option because it needs no signal, no server, and no accounts.

Is Statbotics EPA enough on its own to build a picklist?

No. EPA is an excellent prior on team strength, but it's computed from the same public results everyone has, so it can't see reliability, defense, driver skill, or the reason a number is misleading. Use EPA to seed your rankings, then let your own match scouting adjust teams up or down. Per-robot averages also stay noisy until you have several observations, so lean on EPA and pit data early in an event and trust scouted means more by the back half of quals.

Start smaller than you think you need. A flat table, one clean pull from TBA, EPA from Statbotics, and a weighted score your strategy lead helped write will out-perform a beautiful app nobody trusts. Get that loop working at one event, watch where it fails at the table, and let the next version fix what you actually hit.

Spot an error or something out of date?Create a free account to suggest an edit

Keep reading

More from the pit

Start learning FRC — free

Structured lessons and quizzes across every department. Create a free account to save your progress, track your team, and earn a certificate.

394lessons
11departments
100%free