I built a blackjack table that runs entirely in the browser — six decks, dealer stands on soft 17, split up to three hands, no framework, no build step, no server. One JS file, one CSS file, PixiJS for rendering.
Code: github.com/sorviboshky-gif/blackjack-free
It worked on my machine. Then it didn't, four separate times, and each failure taught me something I hadn't known.
1. hidden is a suggestion, not a command
The table has a loading screen and a game canvas. Only one should be visible:
<div class="bj-loader" data-loader>…</div>
<div class="bj-stage" data-stage hidden>…</div>
loader.hidden = true;
stage.hidden = false;
Both appeared at once — loader stacked on top of the game, page twice as tall as it should be. No error, nothing in the console.
The hidden attribute works through the user-agent stylesheet, which sets display: none. Any author-level display beats it:
.bj-loader { display: flex; } /* silently wins */
The fix is one line, and every project with a component library should have it:
[hidden] { display: none !important; }
I had this bug in four places — loader, action buttons, bet bar, insurance prompt — all invisible until the exact moment they weren't.
2. Your game deadlocks in a background tab
This one cost the most time.
The deal sequence is a chain of awaited animations:
await dealTo(playerCards, card1, true);
await dealTo(dealerCards, card2, true);
await dealTo(playerCards, card3, true);
await dealTo(dealerCards, card4, false);
Each dealTo returns a promise resolved by the animation loop, which runs on Pixi's ticker — which runs on requestAnimationFrame.
requestAnimationFrame does not fire in a background tab.
Switch tabs mid-deal and the tween never advances, the promise never resolves, await never returns. Come back and the game is frozen forever: cards mid-flight, buttons disabled, busy flag stuck true. Only a reload fixes it. No error, no warning — the code is just sitting there, correctly waiting for a frame that will never come.
I found it because my test environment didn't paint frames at all, so every hand hung. That accident saved me from shipping it.
The fix is a watchdog that advances stalled animations when frames stop arriving:
const now = () => (window.performance || Date).now();
let lastTick = now();
setInterval(() => {
if (!tweens.length) { lastTick = now(); return; }
const gap = now() - lastTick;
if (gap > 260) stepTweens(gap); // frames stopped — catch up manually
}, 120);
setInterval is throttled in background tabs, but it is not stopped. The state machine finishes, promises resolve, and the player returns to a settled hand instead of a corpse.
Anything that awaits an rAF-driven animation has this bug. Most of the time nobody notices, because most of the time the animation isn't load-bearing.
3. backgroundAlpha: 0 did not give me a transparent canvas
I wanted the page background to show through around the table:
await app.init({ backgroundAlpha: 0, antialias: true });
I got a solid black rectangle. getComputedStyle(canvas).backgroundColor reported rgba(0, 0, 0, 0) — the CSS was transparent, the pixels were not.
The WebGL context is created without an alpha buffer, so "transparent" resolves to black. Chasing real transparency means context flags and compositing surprises on mobile.
I stopped chasing it:
await app.init({ backgroundColor: 0x0c102b, backgroundAlpha: 1 });
Paint the canvas the same colour as the section behind it. Visually identical, one less thing to break, and it composites faster.
4. A 21 on split aces is not a blackjack
Not a rendering bug — a rules bug, and the kind reviewers catch instantly.
Split a pair of aces, catch a ten, and you have 21. Naive code pays it 3:2. Every real casino pays it 1:1, because a blackjack is by definition the first two cards dealt to a hand, and a split hand isn't that.
const isBlackjack = (hand) =>
hand.cards.length === 2 && score(hand.cards) === 21 && !hand.fromSplit;
That !hand.fromSplit is the whole rule. Miss it and the house edge shifts in the player's favour by a visible margin — which in a demo is a bug, and in production is a hole.
Same family of edge cases: split aces get exactly one card each and no more, and a natural blackjack beats a 21 assembled from three cards. Both are one line, both are wrong by default.
Bonus: Math.random() * n is biased
A shoe is 312 cards. Fisher–Yates needs an integer in [0, i], and the obvious version has modulo bias:
Math.floor(Math.random() * n) // biased, and unseedable by design
For a demo nobody would notice. It still felt wrong to ship a card game with a shuffle I couldn't defend, so it uses rejection sampling on the crypto RNG:
function rnd(n) {
const a = new Uint32Array(1);
const limit = Math.floor(4294967296 / n) * n; // discard the ragged tail
let v;
do { crypto.getRandomValues(a); v = a[0]; } while (v >= limit);
return v % n;
}
Costs nothing at 312 cards and removes an entire category of "is this rigged" questions.
What the whole thing looks like
The renderer is Pixi, everything else is plain JavaScript. Cards leave the shoe along a quadratic Bézier, rotate in flight, land at a small random tilt and settle with a short overshoot — the flip lifts the card while collapsing it on scale.x. prefers-reduced-motion collapses all of it to 60 ms rather than disabling the game.
Every user-facing string comes from one data-i18n attribute, so translation is swapping JSON — the production build runs the same file in nine languages including Arabic in RTL.
Repository, MIT licensed, no dependencies beyond Pixi: github.com/sorviboshky-gif/blackjack-free
The chips are practice chips. Nothing is won, nothing is deposited, nothing leaves the browser.
Top comments (0)