// The bar a dealer's thumb lives on while a hand is running: who's to act, what it // costs them, and one tap per decision. Sits at the bottom of the screen on a phone // so it's reachable without looking, and re-renders after every action because the // next player's numbers are different. const ActionBar = { state: null, hooks: null, raiseOpen: false, raiseDraft: null, mount(state, hooks) { this.state = state; this.hooks = hooks; this.watchHeight(); this.render(); }, // The bar is fixed to the bottom of the screen, so everything else has to know how // tall it is or the table simply runs underneath it — on a 640px-tall phone the // bottom seats of the oval were sitting behind the Fold button. Its height isn't a // constant either: opening the raise pad grows it. Publishing the measured height // as a custom property lets the layout and the toast both stay clear of it without // anyone guessing a number. watchHeight() { const host = document.getElementById("actionBar"); if (!host || this.heightWatcher) return; const publish = () => { const visible = !host.hidden; const height = visible ? host.getBoundingClientRect().height : 0; document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`); this.publishFeltRoom(height); }; if (typeof ResizeObserver === "function") { this.heightWatcher = new ResizeObserver(publish); this.heightWatcher.observe(host); } window.addEventListener("resize", publish); window.addEventListener("orientationchange", publish); publish(); }, // Half a seat, the gap left under the lowest one, and the smallest felt worth // drawing. A seat is about 73px tall at every width — the puck is text-sized, not // table-sized — so the overhang is a constant rather than something read back off // the layout this is about to change. Measuring it would mean shrinking the felt, // moving the seats, and getting a different answer on the next pass. SEAT_OVERHANG_PX: 37, FELT_GAP_PX: 8, FELT_MIN_PX: 220, // How tall the felt can be and still leave the whole ring of seats above the bar. // // Publishing the bar's height was only half the job. The wide layout never used it: // the felt sizes itself from its own width — a box 72% as tall as it is wide — and // so has no idea what is underneath it. The bar meanwhile grows with the hand, from // 117px to 172px the moment there's a side pot to show, which is exactly when the // dealer most needs to see who is still in. Measured with two side pots, the bottom // seat sat 22px into the bar at 1280x900, 54px at 1600x1000, 23px at 768x1024. // // The wrap's offset is taken in document space rather than viewport space, so // scrolling the page doesn't resize the table under the dealer's hands. publishFeltRoom(barHeight) { const wrap = document.getElementById("pokerTableWrap"); if (!wrap) return; const topInDocument = wrap.getBoundingClientRect().top + window.scrollY; const room = window.innerHeight - barHeight - topInDocument - this.SEAT_OVERHANG_PX - this.FELT_GAP_PX; document.documentElement.style.setProperty( "--feltRoomPx", `${Math.max(this.FELT_MIN_PX, Math.round(room))}px`); }, // Anything to act on? A hand with nobody dealt in doesn't get a bar — and that // still holds during setup, because a seated player is a live seat whether or not // the betting has been opened. An empty table stays uncluttered. isRunning() { return Betting.liveSeats(this.state).length > 0; }, render() { const host = document.getElementById("actionBar"); if (!host) return; host.innerHTML = ""; if (!this.isRunning()) { host.hidden = true; document.body.classList.remove("hasActionBar"); document.documentElement.style.setProperty("--actionBarHeight", "0px"); this.stopTankTimer(); this.tankSeatIndex = null; return; } host.hidden = false; document.body.classList.add("hasActionBar"); host.appendChild(this.renderStatus()); const breakdown = this.renderPotBreakdown(); if (breakdown) host.appendChild(breakdown); if (!this.state.bettingOpen) { host.appendChild(this.renderDealPrompt()); } else if (Betting.isHandOver(this.state)) { host.appendChild(this.renderHandOver()); } else if (this.state.actingSeatIndex === null || this.state.actingSeatIndex === undefined) { host.appendChild(this.renderStreetDone()); } else if (this.raiseOpen) { host.appendChild(this.renderRaisePad()); } else { host.appendChild(this.renderActions()); } this.syncTankTimer(); }, renderStatus() { const row = document.createElement("div"); row.className = "actionStatus"; const street = document.createElement("span"); street.className = "actionStreet"; street.textContent = Betting.streetLabel(this.state.street); row.appendChild(street); const pot = document.createElement("span"); pot.className = "actionPot"; pot.textContent = `Pot ${Money.format(Betting.potSoFar(this.state))}`; row.appendChild(pot); // How many are still in the hand — part of the standard per-street // announcement, and the number a dealer is asked for most often. Before the // deal it counts the table as it will be dealt, so a busted player waiting on a // rebuy isn't announced as one of the players. Read-only: the sitting-out // itself happens when the hand is dealt, not while the bar is being drawn. const counted = this.state.bettingOpen ? this.state : Betting.tableAsDealt(this.state); const remaining = Betting.liveSeats(counted).length; const players = document.createElement("span"); players.className = "actionPlayers"; players.textContent = `${remaining} ${remaining === 1 ? "player" : "players"}`; row.appendChild(players); // Nobody is being asked to act because nobody can — say so, or the bar just // looks like it's waiting for something. if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) { const runout = document.createElement("span"); runout.className = "actionRunout"; const stillIn = Betting.liveSeats(this.state).length; runout.textContent = `All-in — running out the board (${stillIn} players)`; row.appendChild(runout); } const index = this.state.actingSeatIndex; if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) { const player = this.state.seats[index]; const who = document.createElement("span"); who.className = "actionWho"; who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`; row.appendChild(who); const tank = document.createElement("span"); tank.className = "actionTank"; tank.id = "actionTank"; tank.title = "How long this player has been thinking"; tank.textContent = "0:00"; row.appendChild(tank); } // A standing control rather than a toast per action: a dealer taps this bar // constantly, and a toast after every fold would be noise they'd learn to // ignore — which is exactly when they'd miss the one that mattered. const undoLabel = this.hooks.lastActionLabel(); if (undoLabel) { const undo = document.createElement("button"); undo.type = "button"; undo.className = "actionUndo"; undo.title = `Undo ${undoLabel}`; undo.textContent = `↶ ${undoLabel}`; undo.addEventListener("click", () => this.hooks.undoLast()); row.appendChild(undo); } return row; }, // ---- Think timer ---- // // A player who hasn't noticed the action is on them costs more time than anything // else a dealer does, so the bar shows how long the current player has been in the // tank. It's a stopwatch, not a shot clock: nothing happens when it grows, it just // tells the dealer when it's worth a nudge. NUDGE_AFTER_S: 30, SLOW_AFTER_S: 60, tankSeatIndex: null, tankStartedAt: null, tankInterval: null, // Restarts only when the action actually moves — a re-render for any other reason // (a chip edit, a name change) must not put a thinking player back to zero. syncTankTimer() { const index = this.state.actingSeatIndex; const running = index !== null && index !== undefined && !Betting.isHandOver(this.state); if (!running) { this.stopTankTimer(); this.tankSeatIndex = null; return; } if (this.tankSeatIndex !== index) { this.tankSeatIndex = index; this.tankStartedAt = Date.now(); } if (!this.tankInterval) { this.tankInterval = setInterval(() => this.paintTank(), 1000); } this.paintTank(); }, stopTankTimer() { if (this.tankInterval) { clearInterval(this.tankInterval); this.tankInterval = null; } }, // Updates only the timer's own text, so the seconds ticking over never rebuilds the // buttons under the dealer's thumb. paintTank() { const el = document.getElementById("actionTank"); if (!el || this.tankStartedAt === null) return; const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000); const mins = Math.floor(seconds / 60); el.textContent = `${mins}:${String(seconds % 60).padStart(2, "0")}`; el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S); el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S); }, // Shown only once someone is all-in and the money has actually split — a single // undivided pot needs no announcement, and a line that's always there stops being // read. renderPotBreakdown() { // Only worth showing once someone is actually all-in. Mid-street the amounts in // front of players are unequal all the time — the blinds alone are unequal — // and layering that would announce a side pot on every hand before anyone had // even acted. const someoneAllIn = this.state.seats.some( player => player.inHand && Betting.handTotal(player) > 0 && Betting.stackOf(this.state, player) <= 0 ); if (!someoneAllIn) return null; const contributions = this.state.seats.map((player, index) => ({ id: player.id, index, contribution: Betting.handTotal(player), folded: player.folded, })); const nameById = new Map( this.state.seats.map((player, index) => [player.id, HoldemState.displayName(player, index)]) ); return Render.buildPotBreakdown( PotCalculator.layers(contributions), PotCalculator.uncalledReturns(contributions), id => nameById.get(id) ); }, // Which key presses each button. Shown on the button itself and hidden on touch // devices — a shortcut nobody can discover may as well not exist, and a key badge // on a phone is just clutter. KEY_HINTS: { actionFold: "F", actionCheck: "C", actionCall: "C", actionRaise: "R", actionAdvance: "↵", raiseConfirm: "↵", raiseCancel: "esc", }, button(label, className, onClick, sublabel) { const btn = document.createElement("button"); btn.type = "button"; btn.className = `actionBtn ${className}`; const main = document.createElement("span"); main.className = "actionBtnLabel"; main.textContent = label; btn.appendChild(main); const hint = this.KEY_HINTS[className]; if (hint) { const kbd = document.createElement("kbd"); kbd.className = "actionKey"; kbd.textContent = hint; btn.appendChild(kbd); } if (sublabel) { const sub = document.createElement("span"); sub.className = "actionBtnSub"; sub.textContent = sublabel; btn.appendChild(sub); } btn.addEventListener("click", onClick); return btn; }, // Every action taken from the bar goes through here, which is also where it gets // recorded so it can be taken back. A misclick on Fold is the one mistake in this // tool a dealer can't talk their way out of. commit(label, mutate) { this.hooks.beginAction(label); mutate(); this.hooks.save(this.state); this.hooks.rerender(); }, renderActions() { const index = this.state.actingSeatIndex; const player = this.state.seats[index]; const owed = Betting.toCall(this.state, player); const stack = Betting.stackOf(this.state, player); const who = HoldemState.displayName(player, index); const row = document.createElement("div"); row.className = "actionButtons"; row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index)))); if (owed <= 0) { row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index)))); } else if (owed >= stack) { // Calling would put them all-in, so say that rather than quoting a number // they can't cover. row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack))); } else { row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed))); } if (stack > owed) { // Not "does this player owe anything" — the big blind owes nothing and is // still raising. It's whether there is a live bet to raise. const raising = Betting.wagerVerb(this.state) === "raise"; row.appendChild(this.button(raising ? "Raise" : "Bet", "actionRaise", () => { this.raiseOpen = true; this.raiseDraft = Math.min(Betting.minRaiseTo(this.state), Betting.committed(player) + stack); this.render(); })); } return row; }, // Quick amounts first, keypad second — a dealer calling "raise to sixty" wants a // button, and only reaches for digits when the table does something odd. renderRaisePad() { // A toast is transient news about the action just taken; the raise pad is the // dealer deciding the next one. On a short screen they compete for the same // strip: the pad makes the bar 217px tall on a 640px phone, and the toast — // which floats just above the bar — gets pushed up over the table, covering // seats outright. Rather than squeeze both in, the older one gets out of the // way. Nothing is lost: the toast's Undo is also the bar's own undo control. if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss(); const index = this.state.actingSeatIndex; const player = this.state.seats[index]; const stack = Betting.stackOf(this.state, player); const maxTo = Betting.round(Betting.committed(player) + stack); const pot = Betting.potSoFar(this.state); // Two different minimums, and conflating them was a bug. `minTo` is clamped to // what this player can actually put in, which is what the Min button should // offer. `legalMin` is what the rules require — for a short stack the two are // different, and it's the rules one the warnings have to be measured against, // or a player whose whole stack is under a full raise never gets told that. const legalMin = Betting.minRaiseTo(this.state); const minTo = Math.min(legalMin, maxTo); const raising = Betting.wagerVerb(this.state) === "raise"; const wrap = document.createElement("div"); wrap.className = "raisePad"; const quick = document.createElement("div"); quick.className = "raiseQuick"; const options = [ { label: "Min", value: minTo }, { label: "½ pot", value: Betting.round(this.state.currentBet + pot / 2) }, { label: "Pot", value: Betting.round(this.state.currentBet + pot) }, { label: "All-in", value: maxTo }, ]; options.forEach(option => { const value = Math.max(minTo, Math.min(option.value, maxTo)); const btn = this.button(option.label, "raiseQuickBtn", () => { this.raiseDraft = value; this.render(); }, Money.format(value)); if (Betting.round(this.raiseDraft) === value) btn.classList.add("selected"); quick.appendChild(btn); }); wrap.appendChild(quick); const entry = document.createElement("div"); entry.className = "raiseEntry"; const input = document.createElement("input"); input.type = "text"; input.inputMode = "decimal"; input.className = "raiseInput"; input.value = this.raiseDraft ?? minTo; input.setAttribute("aria-label", raising ? "Raise to amount" : "Bet amount"); input.addEventListener("input", () => { this.raiseDraft = input.value.replace(/[^0-9.]/g, ""); this.updateRaiseNote(wrap, legalMin, maxTo); }); entry.appendChild(input); entry.appendChild(this.button("Cancel", "raiseCancel", () => { this.raiseOpen = false; this.render(); })); // "Raise to $60" and "Bet $10" — a bet isn't *to* anything, there's nothing // under it to raise past. entry.appendChild(this.button(raising ? "Raise to" : "Bet", "raiseConfirm", () => { const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo)); this.raiseOpen = false; const who = HoldemState.displayName(player, index); const label = raising ? `${who}'s raise to ${Money.format(target)}` : `${who}'s bet of ${Money.format(target)}`; this.commit(label, () => Betting.raiseTo(this.state, index, target)); })); wrap.appendChild(entry); const note = document.createElement("p"); note.className = "raiseNote"; note.hidden = true; wrap.appendChild(note); this.updateRaiseNote(wrap, legalMin, maxTo); return wrap; }, // Says something when the amount typed isn't a legal raise. It never blocks — // a dealer overruling the arithmetic is right more often than the arithmetic is, // and the whole tool is built on tapping what the humans actually did. // // But an under-minimum raise from a player with chips behind them is the single // most common thing that stops a home game dead, and until now the bar took it // without a word: type 13 when the minimum is 18 and it was simply accepted. The // engine then treats it as a short raise and correctly doesn't reopen the betting, // which is right for an all-in and wrong for somebody who just misheard the bet. updateRaiseNote(wrap, minTo, maxTo) { const note = wrap.querySelector(".raiseNote"); if (!note) return; const typed = Number(this.raiseDraft); const current = this.state.currentBet || 0; const raising = Betting.wagerVerb(this.state) === "raise"; if (!Number.isFinite(typed) || typed <= 0) { note.hidden = true; return; } // Going all-in for less than a full raise is perfectly legal, and worth saying // out loud because of what follows from it. const isAllIn = Math.abs(typed - maxTo) < Betting.EPSILON_CHIPS; if (typed > current && typed < minTo - Betting.EPSILON_CHIPS && !isAllIn) { // The way out of an under-minimum differs: with a bet in front of you the // alternative is calling it, and with an empty pot there's nothing to call. note.textContent = raising ? `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal raise is ${Money.format(minTo)} or more — otherwise it's a call, or all-in.` : `${Money.format(typed)} is under the ${Money.format(minTo)} minimum. A legal bet is ${Money.format(minTo)} or more — otherwise it's a check, or all-in.`; note.className = "raiseNote raiseNoteWarn"; note.hidden = false; return; } if (isAllIn && typed > current && typed < minTo - Betting.EPSILON_CHIPS) { note.textContent = raising ? `All-in for less than a full raise. Legal, but it doesn't reopen the betting — anyone who has already acted owes the difference or folds.` : `All-in for less than a full bet. Legal, but it doesn't reopen the betting — anyone who has already checked can call it or fold, not raise.`; note.className = "raiseNote raiseNoteInfo"; note.hidden = false; return; } if (typed > current && typed < current + Betting.EPSILON_CHIPS) { note.hidden = true; return; } if (typed > maxTo + Betting.EPSILON_CHIPS) { note.textContent = `More than they have. This goes in as all-in for ${Money.format(maxTo)}.`; note.className = "raiseNote raiseNoteInfo"; note.hidden = false; return; } if (typed <= current) { note.textContent = `${Money.format(typed)} is not above the ${Money.format(current)} already bet — that's a call, not a raise.`; note.className = "raiseNote raiseNoteWarn"; note.hidden = false; return; } note.hidden = true; }, // The table is set but the cards aren't out. This is the step that used to happen // 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. renderDealPrompt() { const wrap = document.createElement("div"); wrap.className = "dealPrompt"; const preview = Betting.blindPreview(this.state); const seated = Betting.liveSeats(this.state).length; const broke = Betting.brokeSeats(this.state); const name = i => HoldemState.displayName(this.state.seats[i], i); const listNames = indexes => indexes.length === 1 ? name(indexes[0]) : `${indexes.slice(0, -1).map(name).join(", ")} and ${name(indexes[indexes.length - 1])}`; const line = document.createElement("p"); line.className = "dealPromptLine"; if (!preview) { // Two players is the least you can deal. Say what's missing rather than // offering a button that would quietly do nothing — and say the *right* // thing: "only one player is dealt in" is wrong when four are sitting there // and three of them are just out of chips. const withChips = seated - broke.length; if (broke.length > 0 && withChips < 2) { line.textContent = withChips === 1 ? `Only ${listNames(Betting.liveSeats(this.state).map(s => s.index).filter(i => !broke.includes(i)))} has chips — ${listNames(broke)} ${broke.length === 1 ? "needs" : "need"} to buy in.` : "Nobody at the table has chips left. Add a buy-in to deal."; } else { line.textContent = seated === 0 ? "Nobody is dealt in yet. Tap a seat to add a player." : "Only one player is dealt in — a hand needs two."; } line.classList.add("dealPromptBlocked"); wrap.appendChild(line); return wrap; } line.textContent = `${name(preview.sbIndex)} posts ${Money.format(preview.smallBlind)}, ` + `${name(preview.bbIndex)} posts ${Money.format(preview.bigBlind)}` + `${preview.headsUp ? " · heads-up" : ""}${preview.manual ? " · blinds set by hand" : ""}`; wrap.appendChild(line); // Dealing relocates a button sitting on an empty chair. That has to be said // before it happens rather than discovered afterwards — a fresh table starts // the button on seat 1 whether or not anyone is sitting there. if (preview.buttonMoves) { const moved = document.createElement("p"); moved.className = "dealPromptLine dealPromptAside"; moved.textContent = `Button moves to ${name(preview.buttonIndex)} — seat ${this.state.dealerSeatIndex + 1} is empty.`; wrap.appendChild(moved); } // Dealing sits out anyone with nothing behind. Said before it happens, for the // same reason the button move is: a dealer shouldn't find out from the felt. if (preview.satOut.length > 0) { const out = document.createElement("p"); out.className = "dealPromptLine dealPromptAside"; out.textContent = `${listNames(preview.satOut)} ${preview.satOut.length === 1 ? "has" : "have"} no chips — ` + `${preview.satOut.length === 1 ? "sitting" : "they sit"} out this hand.`; wrap.appendChild(out); } const row = document.createElement("div"); row.className = "actionButtons"; row.appendChild(this.button("Post blinds & deal", "actionDeal", () => { this.commit("dealing the hand", () => Betting.beginHand(this.state)); })); wrap.appendChild(row); return wrap; }, renderStreetDone() { const row = document.createElement("div"); row.className = "actionButtons"; const isRiver = this.state.street === "river"; const label = isRiver ? "Showdown — resolve pot" : `Deal ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1])}`; row.appendChild(this.button(label, "actionAdvance", () => { if (isRiver) { this.hooks.resolvePot(); return; } this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state)); })); return row; }, renderHandOver() { const row = document.createElement("div"); row.className = "actionButtons"; const live = Betting.liveSeats(this.state); const winner = live.length === 1 ? HoldemState.displayName(live[0].player, live[0].index) : null; row.appendChild(this.button( winner ? `Award pot to ${winner}` : "Resolve pot", "actionAdvance", () => this.hooks.resolvePot() )); return row; }, }; if (typeof module !== "undefined" && module.exports) { module.exports = ActionBar; }