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

85 lines
8.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.