Bet Builder — Selection Compatibility ("Padlock") Rules
"Date: 2026-07-09. Exploration/spec for the multi-selection Bet Builder."
Date: 2026-07-09. Exploration/spec for the multi-selection Bet Builder.
Problem
When a user adds a selection to the builder, some other selections must become unavailable ("padlocked") — exactly like bet365 greys-out/suspends cells. A pair (or set) of selections is invalid when:
- Mutually exclusive — same market, pick one only (France win vs Morocco win).
- Logically contradictory — no single match outcome can make both win (Under 0.5 goals + Both Teams To Score).
- Redundant / implied — one selection is guaranteed by another, so adding it is free and pointless (France win ⇒ "France or Draw" double chance). bet365 keeps these selectable but the combined price = the dominant single; we can either allow-and-collapse or hide them.
Everything else is compatible (and, for pricing, may still be correlated —
that's a separate concern; see bet365-betbuilder-correlation-findings.md).
Core idea — selections as constraints over the outcome space
Don't hand-code every pair. Model each selection as a predicate over match outcome variables, then:
Two selections are compatible ⟺ there exists at least one match outcome that satisfies all their predicates. Selection B is redundant given A ⟺ every outcome satisfying A also satisfies B (A ⊆ B).
This one rule derives every padlock automatically and correctly, including 3-way and n-way combinations, with no matrix to maintain.
Outcome variables
For a single fixture, an "outcome" is an assignment of:
| Var | Meaning |
|---|---|
hg1, ag1 | home / away goals, 1st half |
hg2, ag2 | home / away goals, 2nd half |
hg = hg1+hg2, ag = ag1+ag2 | full-time goals |
hc, ac | home / away corners (FT), + hc1/ac1 for 1st half |
hk, ak | home / away cards |
| … | offsides, fouls, etc. as needed |
Enumerate over a bounded grid (e.g. each goal count 0..8, corners 0..25) and
test satisfiability. The grid is tiny and the check is instant. Variable families
that never interact (goals vs corners vs cards) can be checked independently —
a goals selection can only conflict with another goals/result selection, etc.
Selection → predicate (goals / result family)
| Market selection | Predicate |
|---|---|
| Match Result — Home | hg > ag |
| Match Result — Draw | hg == ag |
| Match Result — Away | hg < ag |
| Double Chance — Home/Draw | hg >= ag |
| Double Chance — Home/Away | hg != ag |
| Double Chance — Draw/Away | hg <= ag |
| Draw No Bet — Home | hg > ag (void if hg==ag, so treat as hg >= ag for feasibility) |
| Total Goals — Over L | hg + ag > L |
| Total Goals — Under L | hg + ag < L |
| Exact Total Goals — N | hg + ag == N |
| Number of Goals — "2 or 3" | hg + ag in {2,3} |
| BTTS — Yes | hg >= 1 && ag >= 1 |
| BTTS — No | `hg == 0 |
| Asian Handicap — Home −h | hg - ag > h (whole-line push handled at pricing) |
| Result Handicap (European) — Home −n | hg - ag > n |
| Team Total (Home) — Over L | hg > L |
| 1st-half variants | same predicates on hg1, ag1 |
| Correct Score a–b | hg == a && ag == b |
| Half Time / Full Time X/Y | HT result on hg1,ag1 AND FT result on hg,ag |
Corners / cards markets map the same way onto hc,ac / hk,ak.
Worked examples (all fall out of §2)
| Selection A | Selection B | Result | Why |
|---|---|---|---|
| Match Result Home | Match Result Away | exclusive | hg>ag ∧ hg<ag unsatisfiable |
| Under 0.5 goals | BTTS Yes | contradiction | hg+ag==0 ∧ hg,ag>=1 |
| Under 0.5 goals | Over 2.5 goals | contradiction | ==0 ∧ >2 |
| Over 3.5 goals | Under 2.5 goals | contradiction | >3 ∧ <2 (any Over L1 with Under L2, L2 ≤ L1) |
| Exact 2 goals | Over 2.5 goals | contradiction | ==2 ∧ >2 |
| Match Result Home | Draw No Bet Away | contradiction | hg>ag ∧ hg<ag |
| Match Result Home | Double Chance Draw/Away | contradiction | hg>ag ∧ hg<=ag |
| Match Result Home | Double Chance Home/Draw | redundant | hg>ag ⇒ hg>=ag |
| Match Result Home | Draw No Bet Home | redundant | hg>ag ⇒ (home DNB) |
| Match Result Home | Home −1.5 handicap | compatible (not redundant) | home win doesn't imply win by 2 |
| Match Result Home | Over 1.5 goals | compatible | 2-0, 3-1… satisfy both (positively correlated → pricing, not padlock) |
| Correct Score 2–0 | Over 2.5 goals | contradiction | total is exactly 2 |
| Correct Score 2–0 | Match Result Home | redundant | 2–0 ⇒ home win |
| BTTS No | Correct Score 1–1 | contradiction | 1–1 ⇒ both scored |
| Over 2.5 corners | Match Result Home | compatible | independent variable families |
Player props
Player props live on their own variables and are almost always independent of team/goals markets — a player's shot/tackle/foul count doesn't constrain the scoreline. The few real links:
- Player to score / Anytime scorer ⇒ that team
>= 1goal ⇒ contradictsUnder 0.5,Team Total Under 0.5,BTTS No(for that team),Correct Score 0-0, etc. Model asteamGoals >= 1. - Two lines of the same player+stat (e.g. Player Shots Over 1.5 and Under 1.5) → mutually exclusive.
- Otherwise treat as independent/compatible (correlation handled at pricing).
Redundancy / implication handling
For A ⊆ B (B guaranteed by A):
- Default (match bet365): keep B selectable; when both are in the slip, the
combined price = the dominant leg (B contributes odds
1.00). Simplest and least surprising. - Optional: grey B with a "already covered" hint instead of a hard padlock.
Implication is detected the same way as contradiction: A ⇒ B iff
satisfiable(A ∧ ¬B) is false.
Implementation sketch
type Sel = { market: string; predicate: (o: Outcome) => boolean; vars: VarFamily };
// compatible if some outcome in the (bounded) grid satisfies all predicates,
// restricted to the union of the selections' variable families.
function compatible(sels: Sel[]): boolean {
const families = union(sels.map(s => s.vars));
for (const o of enumerateOutcomes(families)) { // small bounded grid
if (sels.every(s => s.predicate(o))) return true;
}
return false;
}
// when user has `picked`, a candidate is padlocked if:
function isLocked(picked: Sel[], candidate: Sel): boolean {
if (sameMarket(picked, candidate)) return true; // one-per-market
return !compatible([...picked, candidate]); // no joint outcome
}- Grids stay tiny: goals
0..8each side (81 outcomes), corners0..25, etc. Only enumerate the families the current selections actually touch. - Same-market exclusivity is a cheap short-circuit before the feasibility check.
- Redundancy check (
A ⇒ B) reuses the same engine for the "already covered" UI.
Phasing
- Goals/result family first — highest overlap and the most obvious bad parlays (Match Result, Double Chance, DNB, Total Goals, BTTS, Exact Goals, Number of Goals, Handicaps, Correct Score, HT/FT). One predicate set, one grid.
- Corners, then cards — same engine, separate variable families.
- Player-prop links — the
teamGoals >= 1bridge for scorer markets; same-player+stat exclusivity. - Half-time / full-time consistency — predicates on
*1vars + FT.
Single-selection fair odds (already shipped) + phase 1 padlocks cover the vast majority of logically-broken bets before any correlation modelling.
7b. Markets excluded from the multi entirely (push/refund)
bet365 does not allow markets with a push/refund outcome to join a bet builder at all — you can view their price but not add them to a multi. These are:
- Draw No Bet (voids on a draw)
- Asian handicaps — our "Handicap", "Corner Handicap", "Card Handicap" (Spread markets; whole/quarter lines push or half-win)
Implemented via multiEligible: false on those selections (NO_MULTI_TITLES in
bet-builder.ts); the UI renders them view-only (non-clickable). This sidesteps
the reduced-parlay/void-pricing complexity entirely — void legs simply never
enter a slip. (European "Result Handicap" is a clean 3-way with no refund, so it
stays eligible.)
Open questions
- Whole-line pushes (e.g. Asian Handicap
-1) are void, not lose, on the exact line — for feasibility we treat>=; for pricing the push is handled separately. Confirm this is the right call for padlocking. - Do we hard-lock redundant selections or show "already covered"? (Recommend the latter — matches bet365 and is less frustrating.)
- Player-prop ⇄ team-goals bridge: is our data rich enough to know which team a player is on at selection time? (Yes — the value-bets-v2 group carries teamId.)