tootsies

a discord bot for the tootsies server. ask, recap, discuss, ship features by typing.


Project maintained by mejasonmejason Hosted on GitHub Pages — Theme by mattgraham

The Bookie

A play-money sportsbook inside a Discord bot. It settles real games off live data — and never pays the wrong team.

Bet coins on who wins a live or upcoming game with /bet. The bot freezes the line, watches the game end, and pays out in front of the whole room. It looks like a toy. The hard part hides inside “watches the game end and settles correctly”: there’s no single source of truth for a sports result, and the dropdown you bet from has a 3-second deadline.

The hard problems, in one breath:


What it feels like

A public bet ticket (marcus, 500 on Switzerland, pays 860), then the game auto-settles, then a payout card tagging the winners and losers in the channel.

How it’s wired

Discord commands hit the Bookie cog (a slate cache and a 5-minute settle loop), which reads API-Sports, SportsGameOdds, and The Odds API, all deduped by match_key, and writes three Postgres tables.


1 · A game has no stable ID

Three feeds describe the same match differently — flipped home/away, different spellings (BiH vs Bosnia & Herzegovina), even different ideas of whether it’s still live. So we don’t trust any provider’s ID. We mint our own: sport + the two team names, accent-folded, order-independent, alias-collapsed.

Three providers name one game differently; canonical_team plus sorted() collapse them into a single provider-independent match_key.

The codematch_key + canonical_team
def match_key_parts(sport, home, away, title=""):
    # sorted() → order-independent (a home/away flip can't split it)
    # canonical_team → folds "BiH" and "Bosnia & Herzegovina" onto one name
    teams = "@".join(sorted(t for t in (_canonical_team(home), _canonical_team(away)) if t))
    return f"{_norm(sport)}:{teams}" if teams else f"{_norm(sport)}:{_norm(title)}"

An unresolved name passes through as plain norm, so a miss is at worst a missed merge (caught later by a stranded-bet detector that flags it for an alias add) — never a wrong one. No date component, on purpose: only open bets settle, so a rematch can’t double-settle, and a stale key clears via the 96h void.

utils/sportsdata/models.py · utils/sportsdata/names.py


2 · Settle the money, never wrong

A bet stores the team it backed, not a side. Settlement matches by name — so a provider flipping orientation can’t pay the wrong team — and if a name can’t be reconciled, the bet refunds. The house eats ambiguity; a player never loses a stake to a spelling gap.

A bet goes from open to WON (paid stake x odds), LOST (stake gone), or VOID (refunded); the ambiguous case always refunds.

The decision function — pure, unit-tested, no DB
def bookie_bet_outcome(side, side_label, winner, home, away, stake, decimal_odds):
    if winner is None:                                   # no result
        return "void", stake                             #   → refund
    if winner == "draw":
        return ("won", round(stake * decimal_odds)) if side == "draw" else ("lost", 0)
    if side == "draw":
        return "lost", 0
    sl = _canonical_team(side_label)
    if sl and sl == _canonical_team(winner):
        return "won", round(stake * decimal_odds)        # backed the winner
    if sl and sl in (_canonical_team(home), _canonical_team(away)):
        return "lost", 0                                 # backed the loser
    return "void", stake                                 # can't reconcile → refund, never wrongly lose

db.py:39

Two settlers, one payout. The 5-minute settle loop and the live commentator can both reach a finished game. One transaction that only touches status='open' rows, FOR UPDATE, makes whoever loses the race a clean no-op — so each bet pays exactly once.

The settle loop and the commentator both try to settle; FOR UPDATE on open rows means one commits the payouts and the other no-ops.

The transaction — and the race bug we hit
async def settle_bookie_game(self, game_key, winner, home="", away=""):
    async with self._pool().acquire() as conn, conn.transaction():
        await conn.execute("INSERT INTO bookie_locked_games (game_key) VALUES ($1) "
                           "ON CONFLICT DO NOTHING", game_key)         # lock market, same txn
        rows = await conn.fetch("SELECT ... WHERE game_key=$1 AND status='open' FOR UPDATE", game_key)
        for r in rows:
            status, payout = bookie_bet_outcome(...)                   # pure decision
            await conn.execute("UPDATE bookie_bets SET status=$2, payout=$3, settled_at=NOW() ...")
            if payout:
                await conn.execute("UPDATE bookie_balances SET balance = balance + $3 ...")
        return settled

The commentator deliberately reads the settled rows instead of trusting its own return value: the loop usually wins the race, so the commentator’s call comes back empty — and roasting off that empty result silently blanked the postgame beat (a real bug). Settle, then read who got settled. db.py:2414


3 · The dropdown can’t wait

Discord kills a slash interaction after ~3 seconds. A cold six-league odds fan-out is ~5. So the picker is never allowed to block on a live fetch — it serves a warm cache and refreshes behind.

On a keystroke: warm cache serves instantly; stale serves last-known then refreshes; cold awaits up to 2s then shows a loading hint.

What keeps it instant
  • Warm loop refreshes the cache only while betting is active — an idle server spends zero odds quota.
  • Single-flight: a burst of keystrokes shares one in-flight refresh.
  • Durable slate: the last-good list is persisted and re-seeded at boot, so an auto-deploy (which drops the in-memory cache) doesn’t cold-start /bet.
  • Defer-first: placement calls defer() before any await, so resolving the pick can’t blow the 3s window either.

cogs/bookie.py


4 · Three feeds, never dark

Each outbound feed sits behind its own circuit breaker — a crater trips it and stops the hammering instead of stalling every request. The Odds API is the dual backstop (it fills the slate and settles bets), budget-guarded so a metered key can’t be drained.

SportsGameOdds and API-Sports each behind a breaker; when one craters, The Odds API fills the slate or settles the bets, so the slate stays full and bets still pay out.

The degradation paths
Feed Healthy Craters →
SportsGameOdds primary odds + slate The Odds API fills the picker
API-Sports live scores + finals The Odds API settles open bets
The Odds API idle when others are healthy becomes the only source — 5,000-credit reserve, spends only when a primary is down

One strategy call falls out of this: only offer what we can settle. Settlement is API-Sports-only (soccer + NBA), so an SGO-only sport could be priced and bet but would never settle a win — so the picker never shows it.


The money always conserves

Coins are never created or destroyed: balance equals 10,000 start minus open stakes plus settled payouts; a stake moves from available into in-play rather than vanishing; placement is one atomic transaction that can't go negative or double-spend.

Placement is one transaction — ensure row → daily top-up → check funds → debit → insert — so a balance can’t go negative and a double-spend can’t slip through. If the invariant above ever stops holding, coins were conjured: it’s the single check that proves the ledger.

Odds → payout

Every price, one scale: +130 and −150 American odds and a 0.40 prediction-market price all normalize to a decimal multiplier — 2.30, 1.67, 2.50 — and payout equals stake times the multiplier, so 500 at 2.30 returns 1,150.

A sportsbook line and a prediction-market price reduce to the same decimal multiplier, so they compare and pay on one scale — and the multiplier is frozen at placement.

The exact numbers
Native price → decimal $100 pays implied
+130 American 2.30 230 (profit 130) 43%
-150 American 1.667 167 (profit 67) 60%
0.40 market YES 2.50 250 (profit 150) 40%

The payoff: your bets in the live play-by-play

The bot doesn’t just take bets — it calls the games, live, in a sharp bartender voice, and folds the Bookie into the broadcast on the bookend beats:

Two Discord broadcast cards from Toots: a pregame note that marcus is on Switzerland and devin's on the draw at +860, then a full-time card on a 2-2 draw where marcus's bet is gone and devin's draw call cashed.

It settles the game with that same idempotent transaction, then hands the results to the commentary as grounded context (names and numbers only, never invented). It’s the moment the feature stops feeling like a slash command and starts feeling like a sportsbook with a live announcer who knows your name.

File map
Concern Location
Cog: commands, picker, settle loop cogs/bookie.py
Ledger + settlement transactions db.py
Settlement decision (pure) db.py:39 bookie_bet_outcome
Game identity utils/sportsdata/models.py, utils/sportsdata/names.py
Odds math (pure) utils/odds_compare.py
Feeds + circuit breakers utils/markets.py, utils/the_odds_api.py, utils/api_sports.py

Part of the Tootsies engineering series. Source: github.com/mejasonmejason/tootsies.