Case study · web app · live
NCHSAA Bracket Projector
North Carolina high school baseball and softball teams find out their playoff seeding when the brackets are published. This pulls the live RPI standings every hour, applies the association's own seeding rules in code, and shows the bracket as it stands right now — two sports, eight classifications, 446 schools.
- live
- React · FastAPI · DynamoDB
- shipped: 2026-05
The problem worth solving
The association publishes RPI standings, and it publishes the rules for turning those standings into a playoff bracket. What it does not publish, until the brackets are final, is the bracket itself. So every coach, parent, and player spends the back half of the season doing the same arithmetic by hand and arguing about the result.
The rules are entirely mechanical. If the standings are a feed and the rules are code, the bracket is just a function of the two — recomputed as often as the standings move.
The seeding rules, in code
The engine applies the association's process end to end: drop ineligible and opted-out teams, sort by RPI, take the top 48 qualifiers (24 in the largest classification), then split the field East and West.
That split is the part worth explaining. The obvious implementation is a longitude threshold — everything east of some line goes East. That produces lopsided brackets the moment a classification's schools cluster on one side of the state. Instead the qualifiers are sorted by longitude and cut exactly in half, so the two regions are always the same size — 24 and 24, or 12 and 12 — no matter how the geography falls that year. Each region is then re-seeded by RPI, and byes go to the top seeds.
Regular-season conference alignment is deliberately not used for the split, even though it ships in the same source file: it answers a different question than "which half of the state is this school in."
How it runs
-
Scheduler
EventBridge invokes the scraper hourly from 6 AM to 11 PM Eastern — the window when standings actually change.
-
Scraper Lambda
Playwright and Chromium in a container image, because the standings only exist as a rendered page. Writes RPI rows into a cache table with a 24-hour TTL.
-
API Lambda
FastAPI behind Mangum on ARM, fronted by an HTTP API gateway. Reads the cached standings, runs the seeding engine, returns a bracket.
-
DynamoDB
On-demand tables for standings, school metadata, name aliases, admin sessions, and — once the postseason starts — frozen bracket snapshots and entered results.
-
CloudFront
One distribution serving the React build from S3 and routing
/api/*to the gateway uncached.
Everything talks over IAM — no connection strings, no private
networking to maintain. Infrastructure is CDK; deploys run from
main through GitHub Actions.
Details that took the longest
- Two names for the same school
- The standings page and the official member-schools file disagree about what schools are called. An alias table maps display names to canonical ones, so a rename upstream is a data fix rather than a code change — and a school that fails to match still appears in the bracket instead of vanishing from it.
- Geocoding once, not per request
- Splitting East from West needs a longitude for all 446 schools. Those are resolved once by a script against the official member-schools export and stored; the geocoding service is never in the request path. A school missing coordinates gets a sentinel value and a predictable placement rather than an exception.
- Fuzzy matching with a human in the loop
- Every school links to its MaxPreps page, matched by fuzzy string comparison against their directory. Confident matches are written automatically; borderline ones queue up in the admin UI for one-click review. Automating the easy 90% and asking a person about the rest beats guessing and being subtly wrong on team pages.
- Projection stops when the postseason starts
- Once brackets are official, a projector is worse than useless — it would contradict reality. Playoff mode freezes a snapshot of the bracket and switches the app to recording actual results, so the same page keeps being the right page.
Stack
React 18 · Vite · TypeScript · Tailwind · FastAPI · Mangum · AWS Lambda · DynamoDB · CloudFront · API Gateway · EventBridge Scheduler · Playwright · AWS CDK · GitHub Actions