8.0 KiB
Side Bets — Design Spec (v1, locked)
Status: design locked, not yet built. Sequenced for Wave 3 (after player score reporting + the home "Upcoming" surface; ideally after web push for bet-open nudges).
No real money, ever. This is an in-tournament engagement layer.
Currency
- Each player gets 10 🪙 tokens fresh at the start of every tournament. No carryover, no seasons (this app has no season concept).
- Whole numbers only.
- No hard bust. If you hit 0 tokens with matches remaining, you get a 1-token "comeback" bet each remaining match so you're never fully benched. Not abusable — comeback stakes are smaller than the 10 you started with, so busting on purpose is strictly worse.
When you can bet
- A match's book opens when the match is set (both teams populated → status
ready). - The book closes the instant the admin starts the match (status
started) — same moment walkouts/announcements play. No bets after start. - This dovetails with score reporting, which only begins once a match is
started.
Bet type 1 — Winner (social pool / parimutuel)
The core, social bet. You bet against your friends, not the house.
- Pick the winning team. Stake
1..(your tokens). - All stakes on the match form one pot. On finalize, everyone who picked the actual winner splits the whole pot proportional to their stake; wrong picks forfeit their tokens to the pot.
- Integer payout rule: floor each winner's proportional share, then hand the leftover tokens out one-each to the largest fractional remainders (tie-break: larger stake, then earlier bet). Guarantees whole tokens and exact pot conservation.
- House floor: a correct bet always returns at least ~1.5× the stake even if nobody took the other side, so thin/late pools still feel worth it. (Tunable.)
- Upset dynamics are automatic: betting the less-popular team pays more because fewer winners split a bigger pot — no odds engine, no stats needed.
- Edge cases:
- Everyone picked the same team → no losers → all stakes refunded (no action).
- Nobody picked the winner → everyone refunded.
- Solo bettor → the house floor applies (this is exactly what it's for).
- Live projected payout: before close, show "if you're right you'd win ~X", ticking up via SSE as friends pile in. Final pot locks at match start.
Bet type 2 — Overtime long shots (house-backed, fixed payout)
NOT a pool — you bet against a fixed multiplier, so including these does not thin the Winner pool. Cumulative "reaches at least N overtimes" tiers, keyed off the match ot_count field (the app tracks multiple OTs):
| Bet | Wins if | Payout |
|---|---|---|
| Reaches OT | ot_count ≥ 1 |
2× |
| Reaches Double OT | ot_count ≥ 2 |
4× |
| Reaches Triple OT | ot_count ≥ 3 |
8× |
Each is an independent yes/no long shot; each additional OT doubles the payout (memorable rule, jackpot feel for triple OT). Multipliers are tunable once real OT frequency is observed.
Leaderboard & payoff
- Per-tournament Side Bets leaderboard, ranked by token count; tie-break by number of correct bets (so a hoarder who never bet ranks below active players at the same count).
- A few badges (global, like existing badges): e.g. "Called the Upset", "House Money", "Stone Cold Lock", "Degenerate" (most all-ins).
Data model (proposed — finalize at build)
- New
betscollection:{ tournament, match, player, market: "winner"|"ot1"|"ot2"|"ot3", pick (team id for winner), stake, status: pending|won|lost|refunded, payout }. - Settlement hooks into match finalization (
finalizeMatch/ thematchSSE): compute Winner pool splits + house-backed OT results, write payouts, update balances. - Per-(player, tournament) balance: start 10 − stakes + payouts (materialized or derived).
UI/UX
- Bettable matches surface on the home Upcoming carousel (Wave 2) + the match card/dock.
- Bet slip sheet: choose market, stake slider, live projected payout.
- Win/loss reveal animation on settle; the reaction rail already exists next to matches.
- Side Bets leaderboard screen — mirror the existing predictions leaderboard component.
Reuses / dependencies
- SSE bus, the predictions leaderboard pattern, the badges system.
- Depends on: score reporting (done), home Upcoming carousel (Wave 2), web push (Wave 2) for "betting's open on the Final" nudges.
Side Bets on the home screen (reserved slot)
The Wave 2 home redesign leaves a labeled slot for this — build it here later:
- A compact "Your Side Bets" card near the top of the Upcoming section, showing your 🪙 token balance, number of open (unsettled) bets, and a link to the Side Bets leaderboard. Hidden if the tournament isn't bettable (no set-but-unstarted matches) or the user has no involvement.
- On each card in the Upcoming carousel (matches that are
ready, not yetstarted), a "Bet" affordance that opens the bet-slip sheet. This is the primary entry point — you bet on matches that are about to start. - Once a match is
started, its bets are locked; the card flips to showing the pool result live as it settles. - The redesign should reserve this vertical space and (in code) leave a clear
{/* SIDE BETS SLOT — see docs/side-bets.md */}marker where the card mounts, so wiring it in later is a drop-in.
Implementation notes for a fresh agent (build this cold)
Context: worktree social branch; changes are typically left uncommitted for review. Read docs/side-bets.md (this file) fully first, then:
- Server fns: follow the
createServerFn(...).validator(zod).middleware([...]).handler(...)+toServerResultpattern. Player-facing (non-admin) fns use.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])and resolve the caller viagetPlayerByAuthId(context.userAuthId)— copytoggleMatchReactioninsrc/features/matches/server.tsas the template. All DB access is server-side viapbAdmin; collections have null rules. - Settlement: hook into
finalizeMatchinsrc/features/matches/server.ts(the single match-finalization path — adminendMatchand playerconfirmMatchScoreboth call it). When a match finalizes, settle its Winner pool (integer largest-remainder split + house floor) and the OT long shots. Emit an SSE event so balances/leaderboard update live. - Bet open/close is derived from match
status: bettable whileready, locked atstarted(identical trigger to score reporting). - Migration: mimic an existing
pb_migrations/*_created_*.js(e.g.1784007613_created_predictions.js) for the newbetscollection — plainmigrate((app)=>{...})with an up + down. Teams collection idpbc_1568971955, playerspbc_3072146508, matchespbc_2541054544, tournamentspbc_340646327. - Client data: use the
useServerQuery/useServerMutationhooks (src/lib/tanstack-query/hooks/*) and the query-key convention seen insrc/features/predictions/queries.ts. Add the new event type tosrc/lib/events/emitter.ts+ handle it insrc/hooks/use-server-events.ts(invalidate side-bets keys, mirroring howmatch/reactionare handled). - Leaderboard UI: mirror
src/features/predictions/components/prediction-leaderboard.tsxand its routesrc/app/routes/_authed/tournaments/$id.predictions.tsxfor a$id.side-bets.tsxroute. - Toasts:
import { toast } from "@/lib/sonner"—toast.success/toast.erroronly. - New feature dir:
src/features/side-bets/{types.ts, server.ts, queries.ts, utils.ts, components/}— keep pure logic (pool split, house floor, OT payout) inutils.tswith unit-testable functions, likesrc/features/predictions/utils.ts. - Design system: Mantine 8 + the app theme; press feedback via the
flxn-presstheme; tasteful motion only (iOS-like easing, 120–350ms) — match neighboring components, don't invent a new look.
Tunables to revisit after first live use
- House floor multiplier (default 1.5×).
- OT multipliers (2× / 4× / 8×).
- Comeback token amount (1).
- Whether an all-agreed pool refunds or voids.