// The blackjack table. // // The browser holds no game. It sends intents — deal, hit, stand, double, split // — and the server answers with the cards you're allowed to see plus the *script* // of how they got there: one event per card off the shoe, in the order the shoe // gave them up. This file's job is to play that script back at a human speed // rather than snapping the finished hand into place. // // Which is also why the hole card works the way it does: the server sends a // "dealer_hole" event with no card attached, because while you are still acting // it hasn't told anyone what that card is. It arrives with the reveal. // // The money is animated on the same principle. Every number the server sends is // also a movement: a stake is chips leaving your pile and landing on the spot, a // payout is chips coming out of the house's rack, a loss is your stack being // taken away. Nothing about the money changes on this table without something // crossing the felt to make it change — including the count in the chip bar, // which is deliberately not updated until the chips that justify it have landed. // // Since split, "the spot" is not one place. A hand has a spot, and a split makes // a second hand with a second bet on a second spot — two bets that win and lose // separately, which is the whole point of splitting and has to look like it. (function () { "use strict"; var root = document.querySelector("[data-blackjack]"); if (!root) return; var FX = window.PeteFX; var dealerEl = root.querySelector("[data-dealer]"); var dTotalEl = root.querySelector("[data-dealer-total]"); var dLabelEl = root.querySelector("[data-dealer-label]"); var verdictEl = root.querySelector("[data-verdict]"); var betting = root.querySelector("[data-betting]"); var actions = root.querySelector("[data-actions]"); var betAmount = root.querySelector("[data-bet-amount]"); var dealBtn = root.querySelector("[data-deal]"); var msgEl = root.querySelector("[data-table-msg]"); // Where the chips live: your pile (in the bar above), a spot in front of each // hand, and the house's rack on the felt. var purseEl = document.querySelector("[data-chips]"); var houseEl = root.querySelector("[data-house]"); var handsEl = root.querySelector("[data-hands]"); var handTpl = root.querySelector("[data-hand-template]"); var bet = 0; // what you're building between hands var base = 0; // what one hand of the last deal cost, for standing it back up var busy = false; // a request is in flight, or cards are still landing var hand = null; // the deal as the server last described it var DEAL_MS = 380; // one card's flight, and the gap before the next var FLIP_MS = 450; var BEAT_MS = 600; // the dealer thinking before they draw out var SPLIT_MS = 340; // the card lifting out of the hand it's leaving var reduced = FX.reduced; function pace(ms) { return reduced ? 0 : ms; } function wait(ms) { return new Promise(function (r) { setTimeout(r, pace(ms)); }); } function say(text, tone) { if (!text) { msgEl.classList.add("hidden"); return; } msgEl.textContent = text; msgEl.classList.remove("hidden"); msgEl.style.color = tone === "bad" ? "#cc3d4a" : ""; } // ---- drawing -------------------------------------------------------------- // // The deck itself — the faces, the pips, the flip — is PeteCards, shared with // every other table in the room. Here a card is always dealt out of the shoe // and always lands with a degree or two of tilt on it, which are this table's // two opinions about a card and the only ones it has. var CARDS = window.PeteCards; function cardEl(face) { return CARDS.el(face); } var turnOver = CARDS.turnOver; // ---- the hands ------------------------------------------------------------- // // A box per hand: its chips, its cards, its total, its own verdict. There is // always at least one, and before a deal that one is where you build your bet — // so the chips you stack are already sitting on the hand they're about to buy. var hands = []; function makeHand(at) { var el = handTpl.content.firstElementChild.cloneNode(true); var spotEl = el.querySelector("[data-spot]"); var box = { el: el, spotEl: spotEl, // where a chip flies to, as opposed to what counts it cards: el.querySelector("[data-cards]"), total: el.querySelector("[data-total]"), verdict: el.querySelector("[data-hand-outcome]"), ranks: [], // what's in it, for a running total while the cards are landing spot: FX.spot({ spot: spotEl, stack: el.querySelector("[data-stack]"), total: el.querySelector("[data-spot-total]"), }), }; if (at === undefined || at >= hands.length) { handsEl.appendChild(el); hands.push(box); } else { handsEl.insertBefore(el, hands[at].el); hands.splice(at, 0, box); } handsEl.dataset.count = hands.length; return box; } // reset tears the row down to n empty hands. It does not preserve chips: a // caller that has some to keep (a deal, whose stake is already on the spot) // reads the amount first and puts it back. function reset(n) { handsEl.innerHTML = ""; hands = []; for (var i = 0; i < (n || 1); i++) makeHand(); } function ensure(n) { while (hands.length < n) makeHand(); } // The bet you build between hands is hand zero's. function betSpot() { return hands[0].spot; } // live marks the hand you're being asked about, and dims the ones you aren't. // // -1 means nobody is acting — the dealer is drawing, or the deal is over — and // that is *not* the same as every hand being inactive: a settled hand is the // thing you are reading, so it goes back to full brightness rather than sitting // there greyed out. Hence the empty string: it matches neither CSS rule. function live(i) { hands.forEach(function (h, k) { h.el.dataset.live = i < 0 ? "" : k === i ? "1" : "0"; }); } // The running total under a hand, worked out from the cards you can see. The // server sends totals too, but only with the finished state — and a hand you're // playing needs to know what it's holding *now*, not once it's over. It's a // readout, never a decision: every ruling is the server's. var TEN = { K: 10, Q: 10, J: 10 }; function totalOf(ranks) { var total = 0, aces = 0; ranks.forEach(function (r) { if (r === "A") { aces++; total += 11; } else total += TEN[r] || parseInt(r, 10) || 0; }); while (total > 21 && aces > 0) { total -= 10; aces--; } return { total: total, soft: aces > 0 }; } function showTotal(box) { if (!box.ranks.length) { box.total.classList.add("hidden"); return; } var v = totalOf(box.ranks); box.total.textContent = v.total + (v.soft ? " (soft)" : ""); box.total.classList.remove("hidden"); } var SHORT = { blackjack: "Blackjack", win: "Won", dealer_bust: "Won", lose: "Lost", bust: "Bust", push: "Push", }; // A hand's own result, on the hand. With one hand this says the same thing as // the pill above it and is a bit redundant; with four it is the only way to // read what happened, because "you win" is not true of all of them. function showHandVerdict(box, h) { var text = h && SHORT[h.outcome]; if (!text) { box.verdict.classList.add("hidden"); return; } box.verdict.textContent = text; box.verdict.classList.remove("hidden"); } // ---- the money on the felt ------------------------------------------------- // settleChips is what the felt does about the outcome, after the cards have // finished telling you what it is. Each hand is paid on its own, in order, left // to right — because each hand *is* its own bet, and a split that lost one and // won the other has to be watchable as exactly that. function settleChips(final) { var chain = Promise.resolve(); (final.hands || []).forEach(function (h, i) { chain = chain.then(function () { var box = hands[i]; if (!box) return; var payout = h.payout || 0; if (payout <= 0) { // The house takes it. The stack goes to the rack and doesn't come back. return box.spot.sweep(houseEl, h.bet, { gap: 45, lift: 0.6, fade: true }); } // The house pays first, into the spot beside the stake, so you watch the // winnings arrive on top of the bet that earned them. var back = payout - h.bet; return box.spot .pour(houseEl, back, { gap: 60 }) .then(function () { return wait(back > 0 ? 340 : 160); }) .then(function () { return box.spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); }); }); }); return chain; } // While the hole card is down, the dealer's total is only what's showing — so // say so, rather than printing a number that quietly means something else. function dealerTotal(v) { if (v.dealer && v.dealer.length) { dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total); dTotalEl.classList.remove("hidden"); } else { dTotalEl.classList.add("hidden"); } } // paint puts a deal on the felt with no animation. This is the resume path: you // reloaded, or Pete restarted, and your cards are simply there — including the // stakes, which are still on their spots because the server still has them. function paint(v) { dealerEl.innerHTML = ""; if (!v) { reset(1); setPhase(null); return; } reset(v.hands.length || 1); v.hands.forEach(function (h, i) { var box = hands[i]; (h.cards || []).forEach(function (c) { box.cards.appendChild(cardEl(c)); box.ranks.push(c.rank); }); box.spot.render(v.phase === "done" ? 0 : h.bet); showTotal(box); showHandVerdict(box, v.phase === "done" ? h : null); }); v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); }); if (v.hole) dealerEl.appendChild(cardEl(null)); dealerTotal(v); setPhase(v); } var VERDICTS = { blackjack: "Blackjack! 🎉", win: "You win!", dealer_bust: "Dealer busts. You win!", lose: "Dealer takes it.", bust: "Bust.", push: "Push — your bet comes back.", }; function verdict(v) { var text = VERDICTS[v.outcome] || ""; // Across several hands "you win" is a claim about the money, not about the // cards: you can win one, lose one, and still be up. The pill reports the // deal; the badge on each hand reports the hand. if ((v.hands || []).length > 1) { text = v.net > 0 ? "You're up on the deal." : v.net < 0 ? "Down on the deal." : "All square."; } if (!text) { verdictEl.classList.add("hidden"); return; } if (v.net > 0) text += " +" + v.net.toLocaleString(); else if (v.net < 0) text += " " + v.net.toLocaleString(); verdictEl.textContent = text; verdictEl.classList.remove("hidden"); // The one thing in this room that gets confetti. A natural is rare, it pays // 3:2, and if everything celebrated then nothing would. // // The *sound* is not so precious: a win is a win and you should hear it. So // the fanfare rides on the money, not on the confetti. if (v.outcome === "blackjack") FX.burst(verdictEl, { count: 34 }); else if (v.net > 0) FX.sfx("win"); else if (v.net < 0) FX.sfx("lose"); else FX.sfx("push"); } // setPhase swaps the controls: bet between hands, act during one. function setPhase(v) { hand = v; var acting = !!v && v.phase === "player"; betting.classList.toggle("hidden", acting); actions.classList.toggle("hidden", !acting); if (acting) { var dbl = actions.querySelector('[data-move="double"]'); var spl = actions.querySelector('[data-move="split"]'); if (dbl) dbl.disabled = !v.can_double; if (spl) spl.disabled = !v.can_split; live(v.active || 0); } else { live(-1); } if (!acting) verdictEl.classList.toggle("hidden", !(v && v.outcome)); } // ---- the script ----------------------------------------------------------- // play walks the server's events, one card at a time. It is deliberately the // only thing that renders during a hand: the final state is applied at the end, // so what you watch and what the server says can't disagree halfway through. // // `money` is the one exception, and it's a deliberate one. On a hand that is // still running, the chip bar is right immediately — your stake left your pile // when you pressed Deal, and it's sitting on the spot where you can see it. On // a hand that *settles*, the bar is left alone until the chips have physically // come home, because a counter that pays you before the dealer has turned over // is a counter that has told you the ending. function play(view, money) { var events = view.events || []; var final = view.hand; var settles = !!final && final.phase === "done"; var hole = null; // the face-down card element, once one has been dealt var chain = Promise.resolve(); var drew = false; // has the dealer drawn since the reveal? if (!settles) money(); // A deal whose bet was typed rather than stacked (you kept last hand's number // and just pressed Deal) has chips to put down before the first card does. // Everything else that costs money — a double, a split — announces itself as // an event, and pays for itself there. if (final && final.hands && final.hands.length && events.length && events[0].kind === "deal") { var want = final.hands[0].bet; var have = betSpot().amount; if (want > have) { chain = chain.then(function () { return betSpot().pour(purseEl, want - have); }); } } events.forEach(function (e) { chain = chain.then(function () { var box; switch (e.kind) { case "deal": // Clear the felt, but not the stake: those chips are yours, they are // already on the spot, and they are what this deal is riding on. var staked = betSpot().amount; dealerEl.innerHTML = ""; reset(1); betSpot().render(staked); verdictEl.classList.add("hidden"); FX.sfx("shuffle"); return; case "player_card": ensure(e.hand + 1); box = hands[e.hand]; box.cards.appendChild(cardEl(e.card)); box.ranks.push(e.card.rank); showTotal(box); live(e.hand); FX.sfx("deal"); return wait(DEAL_MS); case "split": // The second card lifts out of the hand it was in and becomes a hand // of its own, and the bet that follows it is a second bet: the same // chips again, out of your pile, onto a spot that didn't exist a // moment ago. var src = hands[e.hand]; var moved = src.cards.lastElementChild; var stake = src.spot.amount; var fresh = makeHand(e.hand + 1); if (moved) moved.classList.add("bj-splitting"); FX.sfx("deal", { v: 1 }); return wait(SPLIT_MS).then(function () { if (moved) { moved.classList.remove("bj-splitting"); fresh.cards.appendChild(moved); } fresh.ranks.push(src.ranks.pop()); showTotal(src); showTotal(fresh); return fresh.spot.pour(purseEl, stake); }); case "double": // The stake goes down again on that hand, and only that hand. box = hands[e.hand]; return box.spot.pour(purseEl, box.spot.amount); case "dealer_card": // The dealer takes a moment before the first card they draw out. // Card, card, card with no breath in between is a machine dealing; // the pause is the only thing on this table that plays as suspense. var beat = drew ? Promise.resolve() : think(); drew = true; live(-1); return beat.then(function () { dealerEl.appendChild(cardEl(e.card)); FX.sfx("deal", { v: 1 }); return wait(DEAL_MS); }); case "dealer_hole": hole = cardEl(null); dealerEl.appendChild(hole); FX.sfx("deal", { v: 2 }); return wait(DEAL_MS); case "reveal": // The hole card turns over. Its face is in the final hand — this is // the first moment the server has been willing to say what it was. live(-1); if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]'); if (hole && final && final.dealer && final.dealer[1]) { turnOver(hole, final.dealer[1]); } return wait(FLIP_MS); case "settle": return; } }); }); return chain.then(function () { if (!final) { paint(null); money(); return; } dealerTotal(final); if (!settles) { setPhase(final); return; } // The deal is over: nothing is on offer while the money is moving. The // actions go now, and Deal comes back at the far end. actions.classList.add("hidden"); live(-1); (final.hands || []).forEach(function (h, i) { if (hands[i]) showHandVerdict(hands[i], h); }); verdict(final); // The chips move, and the bar catches up with them when they arrive. The // betting controls come back last, once the felt is clear: offering Deal // over a table that is still being paid out invites a click the table then // has to refuse. return settleChips(final) .then(money) .then(function () { return standing(base || unitBet(final.hands[0])); }) .then(function () { setPhase(final); }); }); } // standing leaves your bet up for the next hand, the way you would at a table: // one hand's stake goes straight back on the spot. It costs nothing — chips on // the spot are a proposal until you press Deal — and it's what keeps the number // in the panel honest, because otherwise a settled hand leaves "your bet: 300" // printed over an empty spot. // // It's one hand's worth, not the whole deal's: a split cost you four hundred, // and standing four hundred back up on one spot would be betting a stranger's // money on your behalf. // unitBet is what one hand of a deal actually cost, read back off a settled // hand. It exists for the reload: `base` only knows what you pressed Deal with, // and if you reloaded mid-hand nobody pressed Deal. A doubled hand carries twice // the stake it was dealt with, so standing its bet back up would quietly put you // on double the number you thought you were playing. function unitBet(h) { if (!h) return 0; return h.doubled ? h.bet / 2 : h.bet; } function standing(amount) { var money = window.PeteGames.view(); if (!amount || !money || money.chips < amount) { bet = 0; showBet(); return; } bet = amount; showBet(); return betSpot().pour(purseEl, amount); } // think is the dealer's beat: a pause with something to look at, so it reads as // deliberation rather than as the page having hung. function think() { if (reduced || !dLabelEl) return wait(0); dLabelEl.classList.add("pete-dealer-think"); return wait(BEAT_MS).then(function () { dLabelEl.classList.remove("pete-dealer-think"); }); } // ---- talking to the table ------------------------------------------------- function send(path, body) { if (busy) return; busy = true; say(""); return window.PeteGames.post(path, body) .then(function (view) { // play() decides *when* the money lands; see the note on it. return play(view, function () { window.PeteGames.apply(view); }); }) .catch(function (err) { say(err.message, "bad"); // Whatever we thought was on the felt, the server is the authority on it. return window.PeteGames.refresh().then(function (v) { if (v && !v.hand) { reset(1); setPhase(null); } else if (v && v.hand) paint(v.hand); }); }) .then(function () { busy = false; }); } // ---- betting -------------------------------------------------------------- // // A bet is built by putting chips on the spot, one at a time, and it is those // chips the deal then rides on — the number under the pile is a readout of the // pile, not the other way round. function showBet() { betAmount.textContent = bet.toLocaleString(); var money = window.PeteGames.view(); if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet; } // Scoped to buttons: the bare [data-chip] spans in the corner are the house's // rack, and the house is not betting. root.querySelectorAll("button[data-chip]").forEach(function (btn) { btn.addEventListener("click", function () { if (busy) return; var d = parseInt(btn.dataset.chip, 10); var money = window.PeteGames.view(); if (money && bet + d > money.chips) { say("You haven't got that many chips.", "bad"); return; } bet += d; showBet(); // The chip you clicked is the chip that flies: same colour, same size, off // the button and onto the felt. The pile only grows once it gets there — // but the spot's total moves now, so a Deal pressed mid-flight still knows // the chip is on its way and doesn't put a second one down. var target = bet; var spot = betSpot(); spot.amount = bet; FX.fly(btn, hands[0].spotEl, { denom: d }).then(function () { if (bet >= target) spot.render(target); // unless Clear got there first }); }); }); var clearBtn = root.querySelector("[data-bet-clear]"); if (clearBtn) { clearBtn.addEventListener("click", function () { if (busy || !betSpot().amount) { bet = 0; showBet(); return; } betSpot().sweep(purseEl, null, { gap: 40, lift: 0.7 }); bet = 0; showBet(); }); } if (dealBtn) { dealBtn.addEventListener("click", function () { if (bet <= 0) { say("Put something on it first.", "bad"); return; } base = bet; // one hand's worth, which is what a split doubles and a stand puts back send("/api/games/blackjack/deal", { bet: bet }); }); } root.querySelectorAll("[data-move]").forEach(function (btn) { btn.addEventListener("click", function () { send("/api/games/blackjack/move", { move: btn.dataset.move }); }); }); document.addEventListener("keydown", function (e) { if (e.metaKey || e.ctrlKey || e.altKey) return; if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return; if (!hand || hand.phase !== "player" || busy) return; var move = { h: "hit", s: "stand", d: "double", p: "split" }[e.key.toLowerCase()]; if (!move) return; if (move === "double" && !hand.can_double) return; if (move === "split" && !hand.can_split) return; e.preventDefault(); send("/api/games/blackjack/move", { move: move }); }); // The money bar owns the first fetch; the table picks up whatever it found, // including a deal left sitting on the felt by a reload or a redeploy. reset(1); var resumed = false; window.PeteGames.onUpdate(function (v) { if (!resumed) { resumed = true; if (v.hand) paint(v.hand); if (v.hand && v.hand.phase === "done") verdict(v.hand); } showBet(); }); })();