Preston Choate
← All projects

CFB Dynasty Platform

A SaaS multi-tenant platform for managing EA Sports college football dynasties and keeping league members engaged with coins, betting, and automated lines.

Go Next.js MySQL Discord Coolify OAuth
CFB Dynasty Platform Discord bot showing user power rankings

Overview

Commissioner’s Office is a SaaS multi-tenant system for managing college football dynasties and driving engagement inside a league. It started as a joke among friends about what the betting line should be on our user-vs-user games, and grew into a Discord bot, HTTP API, and web dashboard that multiple Discord servers can run independently.

Right now we are in beta: live in two servers for our friend group and one external server testing whether the platform fits their league. Public links and onboarding are coming after beta wraps and we move to a dedicated domain.

How it started

Four of us played CFB 25 constantly. We would joke about lines for games we played against each other. Near the end of that game cycle, my friend Kevin and I started talking about expanding into a “Super League” with more people. We knew we wanted a place to track user records and run the betting idea for real.

We scoped the basics early:

  • Track which user controls which team
  • Give everyone a fake coin balance at the start of each season
  • Set betting lines so people could wager coins on matchups

It worked really well. The first version was a single Go binary that handled Discord commands and persisted everything in SQLite. Good enough to get started. I never expected it to grow into what it is now.

From there we kept adding features (the changelog tells the real story). The big architectural shifts came when I moved to MySQL for multi-tenant concurrency, split the data layer into its own API binary, and built a frontend dashboard so new leagues could onboard and configure things without living entirely in Discord.

The coin economy

We call the currency coins. Each user starts a season with 1,000 coins. At season end, Kevin donates $50 to each player to buy something in real life for the school they control in-game. That little real-world tie-in keeps people invested in the fake economy.

When a user game settles, the winner gets a 10% commission on the total coins bet on that matchup. Admins can add or remove coins manually, though we rarely need to. We have also handed out bonus coins for in-game awards, records, and (for a while) funny memes that made us laugh.

That meme bonus turned into a full feature: you can configure a channel and emoji so that when users react with something like :trophy:, the poster earns configurable bonus coins and the post gets mirrored into a hall-of-fame channel. In our league, every unique :trophy: reaction is worth 10 coins and the post lands in #meme-hof.

Coins reset to 1,000 for everyone at the start of each in-game season.

League settings as data

Features like meme HoF bonuses, timezones, and deadline reminders are not hard-coded per league. They live in a flat JSON map on each guild row. That let us add configurability without redeploying the bot every time a commissioner wanted different rules.

// internal/guildsettings/keys.go (excerpt)
SettingGuildTimezone           = "guild_timezone"
SettingDefaultDeadlineHours    = "default_deadline_hours"
KeyHallOfFameEmoji             = "hall_of_fame_emoji"
KeyHallOfFameCoinsPerReactor   = "hall_of_fame_coins_per_reactor"
KeyNotificationChannelID       = "notification_channel_id"

Commissioners can change most of this from Discord (/league-settings) or the web settings page. Same keys, same validation on the API either way.

What I’m most proud of: line prediction

The moment I thought “we might have something really cool here” was when automated line generation started working well. The model uses previous user games, ELO ratings, and in-game team ratings to predict how a matchup should look, then turns that into spread and over/under lines without commissioners guessing or doing manual math.

Tuning the inputs has been a mini puzzle. For fun I built a side project that fed real college football stats into a similar model to predict real games and power rankings. It was high on Indiana and Miami before most of the public caught on, and it nearly nailed the Ole Miss vs Miami playoff semifinal. That is a separate experiment, but it came directly out of trying to make dynasty lines feel fair.

How lines get calculated

The calculator builds an effective ELO for each side from three inputs, then converts that into win probability, point spread, and over/under. Most of the “magic numbers” are tunable constants I picked to feel like college football and ELO conventions, not laws of nature.

Where the inputs come from

InputSource
HomeUserELO / AwayUserELOPer-user skill rating (starts around 1500, moves with game results)
homeStatsELO / awayStatsELODerived from season game_results (points, yards, explosives, turnovers vs league averages)
HomeTeamOverall / AwayTeamOverallCommissioner-set team ratings on a 0–100 scale (NCAA-style)
HomeFieldAdvLearned home-field edge in points for that team (added before converting to ELO)

What the constants mean

ConstantMeaning
0.45 / 0.45 / 0.10How much each signal counts toward effective ELO: user skill, recent performance, team rating
1200 + rating * 6Maps 0–100 team overall to an ELO-like scale (~1200 weak to ~1800 elite)
HomeFieldAdv * 10Converts ~3 points of home-field advantage into ~30 ELO before the probability formula
400 in the Pow(10, …/400)Standard chess-ELO divisor: ~400 rating gap ≈ 10:1 win odds
eloWeight = 0.4 (elsewhere)When blending ELO-based spread vs stats-based spread, 40% ELO / 60% box-score margin
28 ELO per point (via eloToSpread)Rough rule of thumb: 280 ELO gap ≈ 10-point spread on the board

The production path also blends an ELO-implied spread with a stats-implied spread, then derives moneyline and O/U from the same margin so everything stays consistent. The heart of it is still building homeEffectiveELO and awayEffectiveELO:

// internal/db/bettingLineCalculator.go (excerpt, annotated)

// Box-score ELO: points/yards/explosives/turnovers vs league averages → ~1000–2000 scale
homeStatsELO := calc.calculateStatsBasedELO(factors, true)
awayStatsELO := calc.calculateStatsBasedELO(factors, false)

// Map commissioner team overall (0–100) onto ELO scale: 50 overall → 1500, +6 ELO per point of rating
homeTeamELO := 1200 + float64(factors.HomeTeamOverall)*6
awayTeamELO := 1200 + float64(factors.AwayTeamOverall)*6

// Weighted blend into one number per side
homeEffectiveELO := factors.HomeUserELO*0.45 + homeStatsELO*0.45 + homeTeamELO*0.10
awayEffectiveELO := factors.AwayUserELO*0.45 + awayStatsELO*0.45 + awayTeamELO*0.10

// HomeFieldAdv is stored in points (e.g. ~3.0); *10 ≈ +30 ELO bump for the home user
homeEffectiveELO += factors.HomeFieldAdv * 10

// Classic ELO win probability: 400-point gap ≈ 76% favorite, 800 ≈ 91%, etc.
expectedHome := 1.0 / (1.0 + math.Pow(10, (awayEffectiveELO-homeEffectiveELO)/400))

Performance stats come from game_results, not legacy stat tables, so lines stay accurate as commissioners enter box scores for the season. Inside calculateStatsBasedELO, stats are normalized against league averages for that season (with fallbacks to all-time or hardcoded norms if the league is new), then clamped so no side collapses below 1000 or above 2000 ELO.

Settlement is real betting logic

Generating a line is one half. Settlement uses the same spread convention as the line generator: matchup.Line is always the home team’s American spread (negative when home is favored, e.g. -7.5 means home gives 7.5 points).

How to read the spread math

lineMeaningHome covers if…
-7.5Home favored by 7.5home_score + (-7.5) > away_score (home wins by more than 7.5)
+3.0Home is a 3-point underdogHome can lose by up to 3 and still cover

The evaluator computes an adjusted margin (result). Positive means the bettor’s pick covered; zero is a push; negative is a loss. Payout uses American odds (guild default, usually -110, or per-matchup override).

OutcomeWhat happens to coins
WinPayout = stake × payoutRatio (ratio includes returned stake, e.g. -110 → ~1.909×)
PushFull stake returned
LossStake already debited at bet time; payout is 0
// internal/db/betEvaluator.go (excerpt, annotated)

// Final scores from the settled matchup
home := float64(*scores.HomeScore)
away := float64(*scores.AwayScore)
line := matchup.Line.Float64 // home spread: negative = home favored

var result float64
if bet.Pick == BetPickHome {
    // Home cover: did home beat the spread?
    // Example: home 28, away 24, line -3.5 → 28 + (-3.5) - 24 = +0.5 (cover)
    result = home + line - away
} else {
    // Away cover: flip perspective; away must beat (home + line)
    result = away - (home + line)
}

if result > 0 {
    // Per-matchup spread odds, or guild default (often -110)
    americanOdds := defaultAmericanOdds
    if matchup.SpreadOdds.Valid {
        americanOdds = matchup.SpreadOdds.Int64
    }
    // e.g. -110 → payoutRatio ≈ 1.909 → 100 coin bet returns ~191 coins total
    return BetResult{Won: true, Payout: e.calculatePayoutWithAmericanOdds(bet.Amount, americanOdds)}, nil
} else if result == 0 {
    return BetResult{Pushed: true, Payout: bet.Amount}, nil // exact push
}
return BetResult{Won: false, Payout: 0}, nil

The same evaluator handles over/under (total vs matchup.OverUnder) and moneyline (straight win/loss, no spread). Winner commission on user games is applied separately when the matchup settles, not inside this function.

What the platform does today

Discord (primary) is still where most people interact with the league. Slash commands cover betting, matchups, balances, power rankings, deadlines, futures, stats entry, and admin tooling. New features usually land in Discord first.

Web dashboard shares feature parity for a lot of workflows, but in a form that is easier for bulk work. Schedule import, mass matchup management, futures, and user administration are all nicer in the browser. Users sign in with Discord OAuth and get guild-scoped access (member vs commissioner) per league, so one login can cover multiple dynasties without mixing data between servers.

Core capabilities include:

  • Multi-guild tenancy with isolated users, matchups, bets, and settings per Discord server
  • Spread, moneyline, and over/under betting with automated settlement
  • User ELO and team game ratings feeding line calculation
  • Futures markets alongside matchup bets
  • Deadline tracking and reminder notifications
  • Hall of Fame / reaction-based coin awards
  • Commissioner settings (timezone, odds defaults, notification channels, and more)

Architecture

The monorepo splits into a Go backend (bot + API) and a Next.js frontend:

cfb-super-league/
├── bot-and-api/     # Go: Discord bot + HTTP API (shared types, separate binaries)
└── frontend/        # Next.js dashboard (Discord OAuth, API proxy)
Discord users  ->  cfb-super-league-bot  ->  cfb-api  ->  MySQL
Web dashboard  ->  Next.js API proxy     ->  cfb-api
cfb-api        ->  Discord REST (deadline notifications)

Bot binary owns Discord: slash commands, guild interactions, and anything that needs to live in the Discord gateway.

API binary owns data access, persistence, and background workers (deadline reminders, schedule vision jobs when enabled).

Frontend talks to the API through a server-side proxy with an API key. Discord OAuth handles login; guild staff vs members get different capabilities on the same UI shell.

Repos are private for now, so this page stays descriptive rather than linking source. At a high level: bot calls API, dashboard calls API, API owns MySQL.

One codebase, two binaries

After the SQLite-to-MySQL move, I split cfb-api and cfb-super-league-bot into separate processes but kept one repo so they share types, migrations, and the store.Store interface.

The bot never touches the database directly. It implements the same store interface over HTTP:

// internal/apiclient/client.go
// Ensure Client implements store.Store.
var _ store.Store = (*Client)(nil)

type Client struct {
    baseURL    string
    apiKey     string
    httpClient *http.Client
}

The API process owns side effects that should not live in the bot loop, including deadline notifications:

// cmd/cfb-api/main.go (excerpt)
import _ "time/tzdata" // IANA zones in minimal containers (e.g. Coolify)

deadlineNotifier = notifications.NewDeadlineNotifier(st, notifications.NewRESTDeadlineMessenger(dc))
deadlineNotifier.Start()

That _ "time/tzdata" import matters once leagues pick their own timezone instead of assuming the host OS has tzdata installed.

Every request is guild-scoped

Multi-tenancy is enforced at the URL layer. The Next.js proxy strips requests to guild paths the logged-in user does not belong to, before the API key ever leaves the server.

// src/app/api/cfb/[...path]/route.ts (excerpt)
const guildIdMatch = apiPathForLog.match(/^\/guilds\/([^/]+)/);
if (guildIdMatch) {
  const guildId = guildIdMatch[1];
  const guild = session.user.guilds?.find((g) => g.id === guildId);
  if (!guild) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }
}
const url = `${CFB_API_URL}${apiPathForLog}`;
headers.set("X-API-Key", CFB_API_KEY);

The API mirrors the same pattern: nearly all data endpoints live under /v1/guilds/{guildId}/... with guild isolation in the database layer.

Why Go and Next.js

Go is my default when it makes sense. Strict typing, fast builds, a great standard library, and easy cross-compilation meant I could ship the backend faster than anything else I would have reached for.

Next.js over plain React because server-side API routes and auth plumbing are built in, and it deploys cleanly on my Coolify VPS alongside everything else on prestonchoate.dev.

Deployment today

All services run on a single VPS through Coolify: bot, API, frontend, and MySQL. One tradeoff I have not solved yet: bot and API build from the same repo, so a push redeploys both. Eventually I will split releases (separate repos, GitHub Actions, or manual deploys) so each binary can scale independently. We are nowhere near needing that yet.

Hard lessons

Deadlines and timezones

Timestamps live in UTC in the database, which is correct. Our league was effectively hard-coded to Eastern time until daylight saving changes broke things. Notifications ended up four hours early. I had to rethink conversion end to end.

The fix was a per-league guild_timezone setting, validation on the API, and doing display/conversion in the data layer instead of splitting behavior across bot and DB. Relevant commits: 80a5e17, 14ed86ec.

Bundling time/tzdata in the API binary avoids silent wrong offsets in Docker images that do not ship OS timezone files.

Moving notifications off the bot

After the bot/API split, the bot was polling the API on a tight loop to see if deadline notifications needed to go out. That created ridiculous log noise from constant HTTP traffic.

The fix was the same architectural instinct as timezones: the API owns deadline notification work. The bot handles Discord interactions; the API runs the notifier goroutine and posts via Discord REST when a reminder is due. One process writes the schedule, one process delivers it.

Still beta (honest status)

  • Schedule import works but the UX is rough. You get almost no feedback while the LLM processes screenshots. That needs a lot of polish before we ship it broadly.
  • Docs and self-onboarding are early. Manual help for new leagues today; proper onboarding is on the near-term roadmap.
  • Licensing is unresolved. We plan a monthly subscription model and need to track which Discord servers are paid, plus what belongs on free vs paid tiers. That can get complicated fast depending on how granular pricing gets.

What’s next

  • Finish beta with our test server and friend leagues
  • Launch on a dedicated domain (not prestonchoate.dev)
  • Improve schedule import UX and onboarding flow
  • Define subscription tiers and enforcement
  • Split bot/API deploy pipelines when it actually matters

In beta. Public dashboard, bot invite, and repo links will go here at launch.

Interested in running this for your dynasty league? Get in touch.

More Projects