A tiny, self-hosted fantasy pool for the Masters Tournament. Pick six golfers, your best four scores each round count, lowest team total wins. Friends submit entries through a Google Form; scores refresh automatically every ~15 minutes via GitHub Actions. A Sunday Showdown adds three R4-only side contests (Pick 3, Champion Call, Boom Holes) via a second form.
Everything is static — there is no server. The site lives on GitHub Pages and the data lives in JSON files committed to the repo.
.
├── index.html # the leaderboard site
├── assets/
│ ├── app.js # client-side renderer + scoring
│ └── style.css
├── data/
│ ├── scores.json # auto-updated by the fetch workflow
│ ├── entries.json # auto-updated by the form-poll workflow
│ └── showdown.json # auto-updated by the Sunday Showdown poll workflow
├── scripts/
│ ├── fetch_scores.py # pulls from ESPN, writes data/scores.json
│ ├── poll_form.py # pulls from the main Google Form sheet, writes entries.json
│ └── poll_showdown.py # pulls from the showdown Google Form sheet, writes showdown.json
└── .github/workflows/
├── update-scores.yml # cron every ~15 min during Masters week
├── poll-form.yml # cron every ~5 min, polls the main form
└── poll-showdown.yml # cron every ~2 min during the Sunday submission window
-
Create a public repo on GitHub and push this directory to it.
git init git add . git commit -m "Initial pool setup" git branch -M main git remote add origin https://github.com/<you>/masters-pool.git git push -u origin main
-
Enable GitHub Pages.
Settings → Pages → Build and deployment → Source: Deploy from a branch → main / (root) → Save. After a minute the site is live athttps://<you>.github.io/masters-pool/. -
Allow Actions to write to the repo.
Settings → Actions → General → Workflow permissions → Read and write permissions → Save. Without this the automated commits will fail. -
Kick off the first scores fetch.
Actions → Update Masters scores → Run workflow → main. This populatesdata/scores.jsonwith the field so picks can be validated and the Field tab on the site can show the player list. -
Create the Google Form (see next section) and set the
FORM_CSV_URLrepo variable.
Friends submit entries through a Google Form you create. Form responses land in a linked sheet; the site polls a CSV export of the sheet every five minutes and merges new submissions into the leaderboard.
-
Create a Google Form. Add these short-answer questions in this order with these exact labels (case-insensitive but otherwise strict):
Display namePick 1Pick 2Pick 3Pick 4Pick 5Pick 6
Mark all of them required. Don't collect emails — none of the parsing needs them.
-
Link a sheet. In the form,
Responses → Link to Sheets → Create new spreadsheet. -
Publish the sheet as CSV. Open the sheet, then
File → Share → Publish to web. PickEntire documenton the left andComma-separated values (.csv)on the right. ClickPublish. Copy the URL — it'll look likehttps://docs.google.com/spreadsheets/d/e/2PACX-1v.../pub?output=csv.The URL is read-only and unguessable, but it's not auth-protected — anyone who has it can read the responses. Fine for a private pool.
-
Set the repo variable.
Settings → Secrets and variables → Actions → Variables tab → New repository variable. Name itFORM_CSV_URL, paste the CSV URL. -
Update the entry button on the site. The "Submit Your Entry" button in the header and the link on the Rules tab both point to a Google Form
viewformURL hardcoded inindex.html. Search fordocs.google.com/formsand replace both occurrences with your form's public URL (the one you get from the form'sSend → link icondialog, ending in/viewform). -
Trigger the first poll.
Actions → Poll Google Form → Run workflow → main. After it runs, any responses already in the sheet are on the board.
After step 6 you're done — the form-poll workflow runs every 5 minutes during Masters week and picks up new submissions automatically.
- Each form row produces one entry. The
displayNamefield is the dedup key: if two rows share the same display name, the latest submission wins and the older one is dropped. - A friend can resubmit by filling out the form again with the same display name; the new picks replace the old.
- Two friends with the same display name will collide. Tell them to add a last initial.
- If you delete a row in the linked sheet, it disappears from the leaderboard on the next poll.
The poller validates every pick against the live field. Misspelled or ambiguous picks don't quietly disappear — they show up on the leaderboard under a Pending fixes section with the bad picks highlighted in red, so the friend can self-diagnose and resubmit.
The poll workflow also logs every row's verdict in the workflow run. If you
want a more granular view: Actions → Poll Google Form → most recent run → poll job.
Three sub-contests sit on top of the main pool, all scored on Sunday's final round only. Friends submit a single secondary form on Sunday morning and their picks are entered in all three contests at once.
| Contest | Picks | Scoring | Tiebreaker |
|---|---|---|---|
| Pick 3 | 3 golfers | Sum of all three R4 to-pars (no drops). Lowest wins. | Full R4 of pick #1 |
| Champion Call | 1 winner + a guess | Must pick the actual winner. Closest winning to-par guess that wasn't too optimistic wins (i.e., can't guess a better score than the winner actually shot). | Smallest absolute diff |
| Boom Holes | 1 golfer | Combined strokes-to-par on holes 12, 13, 15, 16, 18 in R4. Lowest wins. | Full R4 to-par |
Cut survivors only — the showdown picker filters out anyone who got cut,
WD'd, or DQ'd. The submission deadline is 10:30 AM Eastern on Sunday
(constants in assets/app.js and scripts/poll_showdown.py — both must
match if you change one).
-
Create a second Google Form (separate from the main pool form) with these short-answer questions in this exact order:
Display namePick 1Pick 2Pick 3ChampionWinning to-par guessBoom Holes pick
Mark all required.
-
Link a sheet and publish it as CSV (
File → Share → Publish to web → Entire document → Comma-separated values). Copy the URL. -
Set the repo variable.
Settings → Secrets and variables → Actions → Variables tab → New repository variable. Name itSHOWDOWN_FORM_CSV_URLand paste the published-CSV URL. -
Get the prefill entry IDs. In the form's three-dot menu, choose
Get pre-filled link, fill in any answers, thenGet link. Copy the resulting URL — you'll see segments likeentry.1234567890=foo. Note eachentry.NNNNID and which question it belongs to. -
Update
SHOWDOWN_FORM_PREFILLinassets/app.js. Replace the sevenREPLACE_*placeholder values with your form's base URL and entry IDs. The picker auto-hides itself until the base URL no longer containsREPLACE_, so the site will quietly skip the showdown picker until you wire it up. -
Trigger the first poll.
Actions → Poll Sunday Showdown form → Run workflow → main. Any test entries already in the sheet show up on the Sunday Showdown tab.
The poll workflow's cron is active — it polls every 2 minutes from Saturday 22:00 UTC through the Sunday cutoff (14:30 UTC).
scripts/fetch_scores.py pulls per-hole R4 strokes for every cut survivor
from ESPN's per-competitor linescores endpoint and stores them as an
18-length r4Holes array on each player in data/scores.json. The renderer
sums the boom-hole strokes-vs-par client-side, so changing the boom hole
set is a one-line edit to BOOM_HOLES in assets/app.js (no script
changes required).
- 6 picks per entry, best 4 scores each round count toward the team total. Your two worst picks each round are dropped (and they can change round to round).
- Cut golfers keep their 36-hole to-par total — that score still counts if it's one of your best 4.
- Withdrawals and DQs take their last reported to-par + 10 stroke penalty.
- Lowest team total after Sunday wins.
These constants live at the top of assets/app.js if you want to tweak them.
-
update-scores.ymlruns on cron during Masters week (Apr 9–13 2026) every 15 minutes. It runsscripts/fetch_scores.py, which hits ESPN's public golf leaderboard endpoint and rewritesdata/scores.json. If scores changed it commits and pushes. The script also fetches per-hole R4 strokes from ESPN'slinescoresendpoint for Boom Holes scoring (stored asr4Holeson each player). The site readsscores.jsonon every page load (with cache busting) and re-renders. -
poll-form.ymlruns every 5 minutes during the main entry window (currently disabled — cron commented out since the main pool deadline has passed). It runsscripts/poll_form.py, which fetches the published-CSV form responses, validates picks against the current field, dedups by display name, and rewritesdata/entries.json. The poller is stateless — every run rebuilds the form portion of the file from the current sheet contents, so deleting a row in the sheet eventually removes it from the leaderboard. -
poll-showdown.ymlruns every 2 minutes from Saturday 22:00 UTC through Sunday 14:30 UTC (the showdown submission window). It runsscripts/poll_showdown.py, which fetches the showdown Google Form CSV, validates picks against cut survivors, dedups by display name, and writesdata/showdown.json. Entries that fail validation appear in a "Pending fixes" section on the site with detailed error messages.
GitHub Actions cron is best-effort and may be delayed several minutes when GitHub is busy — fine for golf, not fine for stock trading.
- Manual score refresh:
Actions → Update Masters scores → Run workflow. - Manual form poll:
Actions → Poll Google Form → Run workflow. - Manual showdown poll:
Actions → Poll Sunday Showdown form → Run workflow. - ESPN endpoint changes: edit
scripts/fetch_scores.py. The shape it expects is documented in the parsing functions. Worst case, write the fields you care about intodata/scores.jsonby hand and commit — the site only cares about the file's shape, not where it came from. - A friend can't get their entry to validate: check the Pending fixes section on the site, or open the latest poll workflow run. Names are matched against the Field tab — case-insensitive, accent-insensitive, unique substrings and unique last names work, but typos don't fuzzy-match. The same applies to showdown picks, which match against cut survivors only.
| Where | What |
|---|---|
assets/app.js top |
PENALTY_WD, PENALTY_NULL, BEST_OF, PICKS_REQUIRED, SUBMISSION_CUTOFF |
assets/app.js top |
PICK3_REQUIRED, BOOM_HOLES, SHOWDOWN_PENALTY_WD, FEES, SHOWDOWN_CUTOFF |
scripts/poll_form.py top |
SUBMISSION_CUTOFF (must match app.js) |
scripts/poll_showdown.py top |
SUBMISSION_CUTOFF (must match SHOWDOWN_CUTOFF in app.js) |
.github/workflows/update-scores.yml |
Cron schedule for score fetch |
.github/workflows/poll-form.yml |
Cron schedule for form poll (currently disabled) |
.github/workflows/poll-showdown.yml |
Cron schedule for showdown poll |
scripts/fetch_scores.py |
ESPN endpoint, status mapping, R4 hole-by-hole fetch |
Submission deadlines: cutoffs are enforced in two places each — the picker UI hides itself after the deadline, and the poller drops any form rows with a
submittedAttimestamp at or after the cutoff. Both constants must match if you change one.
- Main pool:
2026-04-09T14:00:00Z(10 AM EDT, Thursday April 9)- Sunday Showdown:
2026-04-12T14:30:00Z(10:30 AM EDT, Sunday April 12)
The site auto-refreshes every 30 seconds on read-only tabs (Pool, Showdown, Leaderboard, Field, Rules, Results). Refresh is suppressed when:
- The user is on a picker tab (Make picks / Make Showdown picks) before its cutoff, to avoid losing in-progress selections.
- A player scorecard modal is open.
The Results tab computes payouts across all four contests (main pool + three showdown), calculates each player's net balance (entry fees paid minus prizes won), and shows the minimum set of transfers needed to settle up.
| Contest | Entry fee | Payout |
|---|---|---|
| Main Pool | $20 | 70/30 split (1st/2nd), 3rd gets entry fee back |
| Pick 3 | $20 | 70/30 split (1st/2nd), 3rd gets entry fee back |
| Champion Call | $10 | Winner-take-all (full refund if no eligible winner) |
| Boom Holes | $10 | Winner-take-all |
cd C:\git\Masters
python -m http.server 8000Then open http://localhost:8000. The site fetches data/scores.json and
data/entries.json over HTTP, so opening index.html directly via file://
won't work — you need a local server.