Deal the straddle, instead of leaving the dealer to fake it

A straddle is a blind raise put out before the cards by the player left of the
big blind, for twice it. Home games run them constantly, and the tool had no
idea they existed — so a dealer's only way through was to deal the hand and have
the straddler "raise" to $4, which gets the money right and the other two rules
wrong.

There are three at once:

  - the amount doubles as it goes left, and re-straddles carry on doubling;
  - first to act is left of the *straddler*, not left of the big blind, and the
    straddler keeps the option when it comes back round even if nobody raised;
  - the minimum raise is twice the straddle. Measured off the big blind
    underneath it, a $2/$4 game prices the next raise at $6, and every player at
    the table will argue — correctly.

Tap Straddle on the deal prompt and the tool posts it, puts the right player on
the clock and prices the raise. The seat wears an STR badge for the hand, read
off what the deal actually posted rather than off the count, so the badge can't
outlive the chips it stands for. It's a one-hand decision, like posting in, and
it clears when the button moves.

The control sits on the deal row rather than a line of its own: measured at
390x844, its own line cost 44px of felt on every deal prompt, straddle or no
straddle, and this bar is docked over the seats. Three buttons across a 360px
phone want to wrap, which made the bar 64px taller and put the bottom seat three
fifths behind it — this layout deliberately doesn't shrink the felt to suit, so
the deal button is given a 7rem floor instead, which keeps the row to one line
and the cost to nothing.

20 new tests, checked by breaking each of the three rules on purpose first: the
wrong first-to-act, the minimum measured off the big blind, and a flat rather
than doubling chain each fail a different one.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-02 03:32:17 -06:00
co-authored by Claude Opus 5
parent b97453fba1
commit fc866597eb
7 changed files with 400 additions and 9 deletions
@@ -208,6 +208,7 @@
<button class="dealerChip" type="button" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span>
<span class="blindBadge strBadge" title="Straddled — the action comes back to this seat">STR</span>
<div class="seatPuckName"></div>
<div class="seatPuckCards"></div>
<div class="seatPuckTotal"></div>
@@ -360,6 +361,23 @@
</p>
</section>
<section class="ruling" data-short="Straddle">
<h3>A straddle is a blind raise</h3>
<p>
The player left of the big blind may put out twice it before the cards,
without looking. It is a blind bet, not a raise made in turn, so it does
three things at once: everyone else acts first, the minimum raise becomes
twice the straddle, and the straddler still has the option when the
action comes back — they may raise even if nobody raised into them.
</p>
<p>
Re-straddles carry on to the left, each doubling again, and stop before
the small blind. Tap <em>Straddle</em> on the deal prompt and the tool
posts it, puts the right player on the clock and prices the raise;
the seat wears an <em>STR</em> badge for the hand.
</p>
</section>
<section class="ruling" data-short="Short all-in">
<h3>A short all-in doesn't reopen the betting</h3>
<p>
@@ -796,6 +796,19 @@ button,
color: #241a05;
}
/* The straddle. It takes the big blind's slot on the puck, which is free by
construction: the chain starts left of the big blind and stops short of the small
one, so no seat ever wears two of these.
Violet rather than a third shade of gold or blue, because the badge a dealer is
looking for mid-hand is the one telling them where the action comes back to.
Dark text on the light end of the ramp, measured 5.76:1 against the darker stop. */
.strBadge {
left: 22px;
background: linear-gradient(180deg, #cbaaf0, #a97fd6);
color: #1b1030;
}
/* A blind the dealer named by hand rather than one the button handed out. Without
the ring the table just looks like it's put the blinds in the wrong place. */
.blindBadge.blindManual {
@@ -3520,6 +3533,38 @@ textarea:focus-visible {
font-weight: 600;
}
/* The straddle rides on the deal row, so it must not take half of it: .actionBtn is
flex: 1 and would have split the row evenly with the button the dealer presses every
hand. It sizes to its own words instead, and stays quiet next to the green one —
choosing a straddle is the exception, dealing is the rule. */
.actionBtn.actionStraddle {
flex: 0 1 auto;
background: linear-gradient(180deg, #33304a, #24223a);
border-color: #4b4770;
color: #e6e1ff;
font-size: 0.82rem;
padding-left: 0.7rem;
padding-right: 0.7rem;
}
.actionBtn.actionStraddleOff {
background: linear-gradient(180deg, #2e2e2e, #232323);
border-color: #4a4a4a;
color: #dcdcdc;
}
/* Three buttons across a 360px phone is a squeeze, and the two ways out are both
worse than this one. Letting the row wrap put the deal button on its own line and
made the bar 64px taller — measured at 360x640, that is the bottom seat three fifths
behind the bar, and this layout deliberately doesn't shrink the felt to suit (see the
max-width: none override, where capping it was tried and rejected). Letting flex
squash freely took the deal button to 84px with its label over three lines.
A floor of 7rem keeps the label to two lines and the row to one, so arming a straddle
costs no felt at all. */
.dealPromptButtons .actionDeal {
min-width: 7rem;
}
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
would otherwise only discover afterwards. */
.dealPromptLine.dealPromptAside {
@@ -592,6 +592,43 @@ const ActionBar = {
// by itself the instant the previous hand ended, which meant blinds were committed
// while people were still sitting down — and the blind badges would drift away from
// the money already posted as seats changed. Now the dealer says when.
// ", Dee straddles $4" — read out as part of what goes in before the cards, because
// that is what it is. Two of them read as "Dee straddles $4, Eve $8": the second
// name doesn't need the verb again, and the bar has no room for it.
straddleClause(preview) {
const straddles = preview.straddles || [];
if (straddles.length === 0) return "";
const name = i => HoldemState.displayName(this.state.seats[i], i);
const [first, ...rest] = straddles;
return `, ${name(first.index)} straddles ${Money.format(first.amount)}` +
rest.map(s => `, ${name(s.index)} ${Money.format(s.amount)}`).join("");
},
// Turning it on, doubling it, and calling it off. Heads-up there is no seat left of
// the big blind to straddle from, so nothing is offered rather than a button that
// would do nothing.
straddleButtons(preview) {
const on = (preview.straddles || []).length;
const max = preview.maxStraddles || 0;
if (max <= 0) return [];
const buttons = [];
const nextAmount = Money.format(this.state.bigBlind * Math.pow(2, on + 1));
if (on < max) {
buttons.push(this.button(
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
"actionStraddle",
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
() => { this.state.straddleCount = on + 1; })));
}
if (on > 0) {
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
() => this.commit("calling the straddle off",
() => { this.state.straddleCount = 0; })));
}
return buttons;
},
renderDealPrompt() {
const wrap = document.createElement("div");
wrap.className = "dealPrompt";
@@ -689,11 +726,14 @@ const ActionBar = {
// A dead small blind is a hand with only one blind in it. Saying "nobody posts
// the small blind" out loud is the whole point — a dealer who reads a preview
// with a blind missing and isn't told why will assume the tool has lost track.
line.textContent = preview.deadSmallBlind
const blinds = document.createElement("span");
blinds.textContent = (preview.deadSmallBlind
? `No small blind this hand, ${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`
: `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` +
`${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}`) +
this.straddleClause(preview) +
`${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`;
line.appendChild(blinds);
wrap.appendChild(line);
// Why a position is missing. Both of these are the dead button rule doing its
@@ -767,7 +807,12 @@ const ActionBar = {
}
const row = document.createElement("div");
row.className = "actionButtons";
row.className = "actionButtons dealPromptButtons";
// Beside the deal button rather than on a line of its own. Measured at 390x844:
// its own line cost 44px of felt on every deal prompt, straddle or no straddle,
// and this row is already there and already 44px tall. It reads in the order it
// happens, too — say the straddle, then deal.
this.straddleButtons(preview).forEach(button => row.appendChild(button));
row.appendChild(this.button("Post blinds & deal", "actionDeal", () => {
this.commit("dealing the hand", () => Betting.beginHand(this.state));
}));
@@ -93,9 +93,60 @@ const Betting = {
satOut,
smallBlind: this.round(state.smallBlind || 0),
bigBlind: this.round(state.bigBlind || 0),
// What the straddles will be if the hand is dealt as it stands, and how
// many more this table could take.
straddles: this.straddleSeats(state),
maxStraddles: this.maxStraddles(state),
};
},
// ---- Straddles ----
//
// A straddle is a blind raise put out before the cards, by the player left of the
// big blind, for twice it. It buys the last word before the flop: everyone else
// acts first, and the straddler still has the option when it comes back round even
// if nobody raised. Re-straddles carry on left, each doubling again.
//
// Home games run them constantly and a dealer has to get three things right at
// once — the amount, who is first to act, and what the minimum raise becomes. The
// tool used to do none of them, so a dealer's only way through was to deal, have
// the straddler "raise" to $4, and hand the action to the wrong player.
// How many straddles this table could take. Everyone but the two blinds can put
// one out; heads-up that is nobody, which is the right answer — the button is
// already the small blind and there is no seat left of the big blind to straddle
// from.
maxStraddles(state) {
return Math.max(0, this.liveSeats(this.tableAsDealt(state)).length - 2);
},
// Who straddles and for how much, walking left from the big blind and doubling.
// Asked of the table as it will be dealt, so a seat about to be sat out for having
// no chips is never handed a straddle it can't post.
straddleSeats(state, count = state.straddleCount) {
const wanted = Math.min(Math.max(0, Number(count) || 0), this.maxStraddles(state));
if (wanted <= 0) return [];
const dealt = this.tableAsDealt(state);
const buttonIndex = HoldemState.buttonSeatForDeal(dealt);
const asDealt = buttonIndex === dealt.dealerSeatIndex
? dealt
: { ...dealt, dealerSeatIndex: buttonIndex };
const { bbIndex } = HoldemState.getBlindSeats(asDealt);
if (bbIndex === null) return [];
const straddles = [];
let index = bbIndex;
let amount = this.round((Number(state.bigBlind) || 0) * 2);
for (let n = 0; n < wanted; n++) {
index = this.nextToAct(asDealt, index);
if (index === null || index === bbIndex) break;
straddles.push({ index, amount });
amount = this.round(amount * 2);
}
return straddles;
},
// Does handing this seat chips also put them back in the game?
//
// At a real table those are one event, and here they were two: a busted player got
@@ -475,6 +526,7 @@ const Betting = {
state.currentBet = 0;
state.actingSeatIndex = null;
state.postedBlinds = null;
state.postedStraddles = [];
return;
}
@@ -505,17 +557,35 @@ const Betting = {
// from one who has just walked up and had the blinds go past them.
state.dealtLastHand = state.seats.filter(p => p.inHand).map(p => p.id);
state.currentBet = this.round(Math.max(
sbIndex === null ? 0 : this.committed(state.seats[sbIndex]),
this.committed(state.seats[bbIndex])));
state.lastRaiseSize = bb || 0;
// Straddles go out after the blinds and before the cards, each one left of the
// last. Posted here rather than left to the dealer to fake as a raise, because
// a raise from that seat would hand the action to the wrong player.
const straddles = this.straddleSeats(state);
straddles.forEach(({ index, amount }) => this.putIn(state, index, amount));
state.postedStraddles = straddles.map(({ index }) => index);
state.currentBet = this.round(state.seats.reduce(
(most, player) => Math.max(most, this.committed(player)), 0));
// A straddle is a blind raise, so what has to be matched to raise again is the
// straddle itself, not the big blind underneath it: $2 blinds with a $4
// straddle make the next raise $8, the same way the $2 blind makes it $4.
// Left alone, the minimum would come out at $6 and the dealer would be talked
// out of it by every player at the table.
state.lastRaiseSize = straddles.length > 0 ? state.currentBet : (bb || 0);
// Heads-up the button is the small blind and acts first before the flop; with
// three or more it's the seat after the big blind. Either way, a blind big
// enough to put that player all-in leaves them nothing to decide.
//
// A straddle moves that seat along: first to act is left of the last straddler,
// and the straddler keeps the option, which falls out of nobody having acted
// yet.
const lastPosted = straddles.length > 0
? straddles[straddles.length - 1].index
: bbIndex;
state.actingSeatIndex = headsUp
? (this.stackOf(state, state.seats[sbIndex]) > this.EPSILON_CHIPS ? sbIndex : this.nextToAct(state, sbIndex))
: this.nextToAct(state, bbIndex);
: this.nextToAct(state, lastPosted);
},
// What a pot-sized raise actually is, as a "raise to" total.
@@ -310,6 +310,14 @@ const Render = {
sbBadge.classList.toggle("blindManual", !!manualBlinds && index === sbIndex);
bbBadge.classList.toggle("blindManual", !!manualBlinds && index === bbIndex);
// A straddle is a blind too, and the seat wearing it is the one the action
// comes back to. Marked from what the deal actually posted rather than from
// the count, so the badge can't outlive the chips it stands for — before
// the cards there is nothing out there yet.
const straddleBadge = node.querySelector(".strBadge");
const straddled = (this.state.postedStraddles || []).includes(index);
straddleBadge.classList.toggle("visible", this.state.bettingOpen && straddled);
this.updatePuckContent(node, player, index);
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
@@ -100,6 +100,14 @@ const HoldemState = {
// the big blind rather than waiting for it. Cleared every hand: it's a
// decision about one deal, not a standing arrangement.
postingIn: [],
// How many straddles are on for the hand about to be dealt. A straddle is
// announced hand by hand, so this is cleared with every other one-deal
// decision rather than standing until somebody turns it off.
straddleCount: 0,
// Which seats actually put a straddle out when the hand was dealt, so the
// felt can badge them. Read off the deal rather than worked out again
// later, the same way the posted blinds are.
postedStraddles: [],
// Set when somebody leaves out of the blinds and the rotation has to hold
// to the seats to keep the big blind moving one player at a time. The
// button then sits on the chair they vacated, and no small blind is posted.
@@ -201,6 +209,11 @@ const HoldemState = {
// asking the whole table to post.
if (!Array.isArray(parsed.dealtLastHand)) parsed.dealtLastHand = [];
if (!Array.isArray(parsed.postingIn)) parsed.postingIn = [];
// None on is the right reading of a save from before straddles existed:
// whatever is in front of those players is already in their committed
// chips, and nothing is owed on top of it.
if (!(Number(parsed.straddleCount) > 0)) parsed.straddleCount = 0;
if (!Array.isArray(parsed.postedStraddles)) parsed.postedStraddles = [];
// A table saved before dealing and betting were separate has no flag, and
// guessing false would show "deal the hand" over a hand already in
@@ -643,6 +656,10 @@ const HoldemState = {
// Posting to come in is a decision about one deal. Carrying it forward would
// charge somebody an extra big blind every hand for the rest of the night.
state.postingIn = [];
// So is a straddle. It is announced hand by hand at a table, and a straddle
// that stayed on would take chips off somebody who never said the word.
state.straddleCount = 0;
state.postedStraddles = [];
state.seats.forEach(p => {
p.folded = false;
p.cards = ["", ""];
+188
View File
@@ -0,0 +1,188 @@
// The straddle: a blind raise put out before the cards by the player left of the big
// blind, for twice it.
//
// Home games run them constantly, and it is three rules at once — the amount, who is
// first to act, and what the minimum raise becomes. Faking one by dealing and having
// the straddler "raise" gets the last of those right and the other two wrong.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
// Six seats, everyone in, button on seat 0: small blind seat 1, big blind seat 2,
// straddle seat 3.
const table = (straddles = 0, extra = {}) => {
const state = makeTable({ seatCount: 6, stack: 200, smallBlind: 1, bigBlind: 2, dealerSeatIndex: 0, ...extra });
state.straddleCount = straddles;
return state;
};
const dealt = (straddles = 0, extra = {}) => {
const state = table(straddles, extra);
Betting.beginHand(state);
return state;
};
describe("what goes in the middle", () => {
test("the seat left of the big blind puts out twice it", () => {
const state = dealt(1);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
});
test("a re-straddle doubles again, one seat further round", () => {
const state = dealt(2);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 8, 0]);
});
test("none of it comes from anywhere but the straddler's stack", () => {
const before = chipsOnTable(table(2));
assert.equal(chipsOnTable(dealt(2)), before);
});
test("the felt is told who straddled, not left to work it out", () => {
assert.deepEqual(dealt(2).postedStraddles, [3, 4]);
assert.deepEqual(dealt(0).postedStraddles, []);
});
});
describe("who is on the clock", () => {
test("action starts left of the straddler, not left of the big blind", () => {
assert.equal(dealt(0).actingSeatIndex, 3, "without a straddle it is the seat after the big blind");
assert.equal(dealt(1).actingSeatIndex, 4);
assert.equal(dealt(2).actingSeatIndex, 5);
});
test("the straddler still has the option when it comes back to them", () => {
const state = dealt(1);
// Everyone calls the straddle round to the big blind.
[4, 5, 0, 1, 2].forEach(seat => {
assert.equal(state.actingSeatIndex, seat, `expected seat ${seat} to be up`);
Betting.call(state, seat);
});
assert.equal(state.actingSeatIndex, 3, "back to the straddler");
assert.equal(Betting.toCall(state, state.seats[3]), 0, "nothing to call");
assert.equal(Betting.isStreetComplete(state), false, "and the street isn't over until they say so");
});
test("the straddler checking closes it", () => {
const state = dealt(1);
[4, 5, 0, 1, 2].forEach(seat => Betting.call(state, seat));
Betting.check(state, 3);
assert.equal(Betting.isStreetComplete(state), true);
assert.equal(state.actingSeatIndex, null);
});
test("the big blind acts in turn, not last", () => {
const state = dealt(1);
Betting.call(state, 4);
Betting.call(state, 5);
Betting.call(state, 0);
Betting.call(state, 1);
assert.equal(state.actingSeatIndex, 2, "the big blind is up before the straddler");
assert.equal(Betting.toCall(state, state.seats[2]), 2, "and owes the difference to the straddle");
});
});
describe("what it costs to raise", () => {
test("the minimum raise is twice the straddle, not the straddle plus a blind", () => {
// $2 blinds, $4 straddle: the next raise is to $8. Measured off the big blind
// instead it comes out at $6, and every player at the table would argue.
assert.equal(Betting.minRaiseTo(dealt(1)), 8);
assert.equal(Betting.minRaiseTo(dealt(2)), 16);
assert.equal(Betting.minRaiseTo(dealt(0)), 4);
});
test("what the table owes is measured off the straddle", () => {
const state = dealt(1);
assert.equal(state.currentBet, 4);
assert.equal(Betting.toCall(state, state.seats[4]), 4, "a fresh player calls the straddle");
assert.equal(Betting.toCall(state, state.seats[1]), 3, "the small blind owes the rest");
assert.equal(Betting.toCall(state, state.seats[2]), 2, "so does the big blind");
assert.equal(Betting.toCall(state, state.seats[3]), 0, "the straddler is already good");
});
test("a raise over the straddle is a raise, and the straddler can still re-raise", () => {
const state = dealt(1);
Betting.raiseTo(state, 4, 12);
assert.equal(state.currentBet, 12);
assert.equal(Betting.minRaiseTo(state), 20, "twice again over a raise of eight");
assert.equal(state.seats[3].hasActed, false, "the straddler has not acted yet");
});
});
describe("how many a table can take", () => {
test("everyone but the two blinds", () => {
assert.equal(Betting.maxStraddles(table(0)), 4);
assert.equal(Betting.maxStraddles(table(0, { seatCount: 3, seated: [0, 1, 2] })), 1);
});
test("heads-up, none — there is no seat left of the big blind to straddle from", () => {
const heads = table(1, { seatCount: 6, seated: [0, 1] });
assert.equal(Betting.maxStraddles(heads), 0);
assert.deepEqual(Betting.straddleSeats(heads), []);
Betting.beginHand(heads);
assert.deepEqual(heads.seats.map(p => p.committed), [1, 2, 0, 0, 0, 0]);
});
test("asking for more than the table has stops at the last seat", () => {
const state = table(0, { seatCount: 4, seated: [0, 1, 2, 3] });
state.straddleCount = 99;
assert.equal(Betting.straddleSeats(state).length, 2);
Betting.beginHand(state);
assert.deepEqual(state.seats.map(p => p.committed), [8, 1, 2, 4]);
assert.equal(state.actingSeatIndex, 1, "round to the small blind, who is first to act");
});
test("a seat with no chips is never handed one", () => {
const state = table(1);
state.seats[3].stack = 0;
// Seat 3 is sat out for the deal, so the straddle passes to seat 4.
assert.deepEqual(Betting.straddleSeats(state), [{ index: 4, amount: 4 }]);
});
});
describe("what it doesn't outlive", () => {
test("the hand it was called for", () => {
const state = dealt(1);
HoldemState.startNextHand(state);
assert.equal(state.straddleCount, 0, "nobody straddles again by accident");
assert.deepEqual(state.postedStraddles, []);
});
test("but it survives the button being pushed around before the deal", () => {
// repostBlinds takes every posted chip back and deals again; a straddle that
// vanished there would have been paid once and never returned.
const state = dealt(1);
const before = chipsOnTable(state);
Betting.repostBlinds(state);
assert.deepEqual(state.seats.map(p => p.committed), [0, 1, 2, 4, 0, 0]);
assert.equal(chipsOnTable(state), before);
});
test("a table saved before straddles existed reads as having none", () => {
const parsed = JSON.parse(JSON.stringify(makeTable({ seatCount: 6 })));
delete parsed.straddleCount;
delete parsed.postedStraddles;
const migrated = HoldemState.migrate(parsed);
assert.equal(migrated.straddleCount, 0);
assert.deepEqual(migrated.postedStraddles, []);
});
});
describe("what the dealer is shown before dealing", () => {
test("the straddles that are coming, and how many more it could take", () => {
const preview = Betting.blindPreview(table(1));
assert.deepEqual(preview.straddles, [{ index: 3, amount: 4 }]);
assert.equal(preview.maxStraddles, 4);
});
test("and the preview matches what dealing actually does", () => {
const state = table(2);
const preview = Betting.blindPreview(state);
Betting.beginHand(state);
assert.deepEqual(preview.straddles.map(s => s.index), state.postedStraddles);
preview.straddles.forEach(({ index, amount }) => {
assert.equal(state.seats[index].committed, amount, `seat ${index + 1}`);
});
});
});