Blockchain Blackjack Game Development
Blockchain Blackjack is one of the most technically interesting challenges in on-chain gaming. The problem is as follows: cards must be dealt randomly and fairly, yet the player should not see the cards until they are "revealed". In off-chain casinos this is trivial — the server knows all cards and shows them step by step. On-chain, all contract state is public, and blockchain randomness is manipulable.
The solution is a commit-reveal scheme combined with Chainlink VRF, or the mental poker protocol. Let's examine both approaches.
Fair Randomness: Chainlink VRF
Chainlink VRF provides verifiable randomness: a number is generated off-chain with cryptographic proof that is verified on-chain. Neither the player nor the operator can predict the result.
Flow for Blackjack:
- Player places a bet, contract requests VRF
- VRF fulfillment (via callback) — contract receives random number, generates deck or initial cards
- Player decides: hit or stand
- If hit — new VRF request for the next card
Problem with "if hit": each VRF request is a delay (1-3 blocks) and additional LINK. For real-time gameplay this is uncomfortable.
Optimization: request entire deck at once
contract Blackjack is VRFConsumerBaseV2Plus {
struct Game {
address player;
uint256 bet;
uint8[] deck; // all 52 cards in encrypted order
uint8 playerIdx; // current index in deck
uint8 dealerIdx;
bool active;
}
mapping(uint256 => Game) public games; // requestId -> game
mapping(address => uint256) public playerGame; // player -> gameId
function startGame() external payable {
require(msg.value >= MIN_BET, "Below minimum bet");
uint256 requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: KEY_HASH,
subId: subscriptionId,
requestConfirmations: 3,
callbackGasLimit: 300000,
numWords: 1, // one large number for shuffle
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
games[requestId] = Game({
player: msg.sender,
bet: msg.value,
deck: new uint8[](0),
playerIdx: 0,
dealerIdx: 4, // dealer takes cards from position 4
active: false // becomes true after fulfillment
});
playerGame[msg.sender] = requestId;
}
function fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) internal override {
Game storage game = games[requestId];
// Fisher-Yates shuffle deterministic from single seed
uint8[52] memory deck;
for (uint8 i = 0; i < 52; i++) deck[i] = i;
uint256 seed = randomWords[0];
for (uint8 i = 51; i > 0; i--) {
seed = uint256(keccak256(abi.encodePacked(seed)));
uint8 j = uint8(seed % (i + 1));
(deck[i], deck[j]) = (deck[j], deck[i]);
}
// First 4 cards dealt immediately: player, dealer, player, dealer
game.deck = new uint8[](52);
for (uint8 i = 0; i < 52; i++) game.deck[i] = deck[i];
game.active = true;
emit GameStarted(requestId, game.player, deck[0], deck[2]); // visible player cards
// deck[1] and deck[3] - dealer cards, deck[1] hidden until end
}
}
After fulfillment the deck is shuffled and fixed. Subsequent moves (hit) take cards from the already generated deck — without new VRF requests. Fast and cheap.
Problem: deck visible on-chain
But the entire deck is stored in game.deck — publicly! Technically a player can read all future cards from storage.
Solution: store only the seed, derive cards deterministically only when they are "revealed":
// Don't store deck, only seed
mapping(uint256 => uint256) private gameSeeds;
function getCard(uint256 gameId, uint8 position) private view returns (uint8) {
// Deterministically compute card from seed and position
// Card not in storage — cannot read in advance
return uint8(uint256(keccak256(abi.encodePacked(gameSeeds[gameId], position))) % 52);
}
This doesn't fully solve the problem: technically one can simulate getCard for all positions in the same block. Full solution — Mental Poker protocol with encryption of each card, but that's considerably more complex.
Blackjack Logic On-Chain
Card values: ace = 1 or 11, face cards = 10, others by rank:
function cardValue(uint8 card) internal pure returns (uint8) {
uint8 rank = card % 13; // 0-12: ace, 2-10, jack, queen, king
if (rank == 0) return 11; // ace (soft value)
if (rank >= 10) return 10; // face cards
return rank + 1;
}
function handScore(uint8[] memory cards) internal pure returns (uint8) {
uint8 score = 0;
uint8 aces = 0;
for (uint i = 0; i < cards.length; i++) {
uint8 val = cardValue(cards[i]);
if (val == 11) aces++;
score += val;
}
// Soft ace becomes hard (1) if bust
while (score > 21 && aces > 0) {
score -= 10;
aces--;
}
return score;
}
Dealer logic on-chain: dealer takes cards while score < 17, stops at 17+ (including soft 17 depending on rules).
Liquidity Management: Bankroll
The contract must have ETH for payouts. House edge in Blackjack ~0.5% with optimal strategy — this is real margin. But variance is high, sufficient bankroll is needed.
Kelly Criterion for maximum bet: with edge e and bankroll B, maximum bet ≈ B * e / variance. For Blackjack with 0.5% edge and ~1.3 variance — maximum bet ≈ 0.38% of bankroll. In practice: limit maximum bet at 1-2% of bankroll.
uint256 public constant MAX_BET_PERCENT = 200; // 2% = 200/10000
function maxBet() public view returns (uint256) {
return address(this).balance * MAX_BET_PERCENT / 10000;
}
modifier validBet() {
require(msg.value >= MIN_BET && msg.value <= maxBet(), "Invalid bet");
_;
}
Frontend and UX
Blockchain Blackjack requires careful UX for asynchronicity. VRF fulfillment is not instantaneous. Player clicks "Deal" — waits 1-3 blocks for cards to appear.
UI flow:
- Bet →
startGame()→ status "Dealing..." (wait forGameStartedevent) - Cards appear → player sees own 2 cards, one dealer card
- Hit/Stand → instantaneous, from shuffled deck
- Stand → dealer reveals cards → outcome → payout
For event polling: wagmi with useWatchContractEvent or WebSocket connection to node.
Stack
| Component | Technology |
|---|---|
| Smart contract | Solidity 0.8.x + VRF v2.5 |
| Testing | Foundry + VRF mock |
| Frontend | React + wagmi + viem |
| Network | Polygon / Arbitrum (low gas) |
| Audit | Mandatory (gambling + custody of funds) |
Audit is critical — the contract holds ETH and pays out winnings. Errors in payout logic or random seed generation — direct loss of funds.
Timeline
Working on-chain Blackjack (smart contract, tests, basic frontend): 4-5 weeks. With complete UI, animations, statistics, and mobile adaptation — 8-10 weeks.







