Files
flxn-app/docs/side-bets.md
2026-07-22 15:47:34 -07:00

8.0 KiB
Raw Permalink Blame History

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 bets collection: { 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 / the match SSE): 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 yet started), 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(...) + toServerResult pattern. Player-facing (non-admin) fns use .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) and resolve the caller via getPlayerByAuthId(context.userAuthId) — copy toggleMatchReaction in src/features/matches/server.ts as the template. All DB access is server-side via pbAdmin; collections have null rules.
  • Settlement: hook into finalizeMatch in src/features/matches/server.ts (the single match-finalization path — admin endMatch and player confirmMatchScore both 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 while ready, locked at started (identical trigger to score reporting).
  • Migration: mimic an existing pb_migrations/*_created_*.js (e.g. 1784007613_created_predictions.js) for the new bets collection — plain migrate((app)=>{...}) with an up + down. Teams collection id pbc_1568971955, players pbc_3072146508, matches pbc_2541054544, tournaments pbc_340646327.
  • Client data: use the useServerQuery / useServerMutation hooks (src/lib/tanstack-query/hooks/*) and the query-key convention seen in src/features/predictions/queries.ts. Add the new event type to src/lib/events/emitter.ts + handle it in src/hooks/use-server-events.ts (invalidate side-bets keys, mirroring how match/reaction are handled).
  • Leaderboard UI: mirror src/features/predictions/components/prediction-leaderboard.tsx and its route src/app/routes/_authed/tournaments/$id.predictions.tsx for a $id.side-bets.tsx route.
  • Toasts: import { toast } from "@/lib/sonner"toast.success / toast.error only.
  • 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) in utils.ts with unit-testable functions, like src/features/predictions/utils.ts.
  • Design system: Mantine 8 + the app theme; press feedback via the flxn-press theme; tasteful motion only (iOS-like easing, 120350ms) — 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.