On-Chain Blackjack: Architecture, VRF, and Provably Fair
Imagine you want to launch an online Blackjack casino on the blockchain. The problem is how to ensure randomness of cards without trusting a centralized server? In an off-chain casino, the server deals cards but the player doesn't see the deck. On-chain, everything is transparent — anyone can read the contract storage. If we pre-generate a deck and store it, the player can see all future cards. The solution is commit-reveal with VRF or Mental Poker. We use Chainlink VRF, but with optimization: generate the entire deck in one request, then cards are revealed gradually without additional VRF calls. Our team has 7+ years of experience in Web3 and has implemented 20+ blockchain games and DeFi projects. Contact us for architecture consultation and audit.
How Chainlink VRF ensures randomness?
Chainlink VRF generates a verifiable random number off-chain with a cryptographic proof that is verified on-chain. No one — neither the player nor the operator — can predict the result. This is 10 times more reliable than storing a seed in the contract.
Flow for Blackjack:
- Player places a bet, contract requests VRF
- VRF fulfillment (via callback) — contract receives random, generates deck or first cards
- Player decides: hit or stand
- If hit — new VRF request for the next card
Problem with "if hit": each VRF request introduces a delay (1-3 blocks) and additional LINK cost. Not comfortable for real-time gaming.
Optimization: request the entire deck at once — blockchain casino blackjack
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 draws 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 one 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 are 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]); // player's visible cards
// deck[1] and deck[3] - dealer cards, deck[1] hidden until end
}
}
After fulfillment the deck is shuffled and fixed. Subsequent moves (hit) draw cards from the already generated deck — no new VRF requests. Fast and cheap.
Why can't the deck be stored openly?
But the entire deck is stored in game.deck — publicly! Technically, the player can read all future cards from storage.
Solution: store only the seed, compute 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 be 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 is the Mental Poker protocol with encryption of each card, but that is significantly more complex.
How to implement Blackjack logic in a smart contract?
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 draws cards while score < 17, stops at 17+ (including soft 17 depending on rules).
How to manage bankroll using Kelly Criterion?
The contract must have ETH for payouts. House edge in Blackjack is ~0.5% with optimal strategy — that's the real margin. But variance is high, so sufficient bankroll is needed.
Kelly Criterion for maximum bet: with edge e and bankroll B, maximum bet ≈ B * e / variance. For Blackjack with edge 0.5% and variance ~1.3 — max bet ≈ 0.38% of bankroll. In practice: limit max bet to 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");
_;
}
Why UX is critical for blockchain blackjack?
Blockchain Blackjack requires careful UX for asynchrony. VRF fulfillment is not instantaneous. Player presses "Deal" — waits 1-3 blocks for cards to appear.
Flow in UI:
- Stake →
startGame()→ status "Dealing..." (wait forGameStartedevent) - Cards appear → player sees their 2 cards, one dealer card
- Hit/Stand → instantaneous, from shuffled deck
- Stand → dealer reveals cards → result → payout
For event polling: wagmi with useWatchContractEvent or WebSocket connection to node.
Tech 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) |
Contract audit: critical security
The contract holds ETH and pays out winnings — errors in payout logic or randomness generation lead to direct losses. We use Slither and Mythril for static analysis, and also recommend an external audit by partners. Your security is our guarantee. Learn more about smart contract audit in Chainlink documentation.
What is included in on-chain Blackjack development?
- Architectural documentation and protocol selection (VRF vs Mental Poker)
- Blackjack smart contract with game logic and bankroll management
- Tests with Foundry (unit, fuzz, integration)
- Chainlink VRF (v2.5) integration with subscription
- Frontend on React + wagmi + viem with wallet support (MetaMask, WalletConnect)
- Deployment to testnet and mainnet (Polygon/Arbitrum on your choice)
- Code review and bug fixes
- Training your team on contract operation
- Technical support during launch
Comparison: VRF vs Mental Poker
| Criteria | Chainlink VRF | Mental Poker |
|---|---|---|
| Implementation complexity | Low | High (cryptography) |
| Speed | 1-3 blocks | 1 round (instant) |
| Gas cost | ~300k gas + LINK | ~500k gas (encryption) |
| Trust | Requires trust in oracle | Fully decentralized |
| Recommendation | For most projects | For projects demanding maximum decentralization |
Mental Poker: how it works
Mental Poker is a cryptographic protocol that allows players to deal cards without a trusted third party. Each card is encrypted with a shared key, then shuffled and decrypted. This provides maximum decentralization but requires more gas and is more complex to implement.Estimated timelines
Working on-chain Blackjack (smart contract, tests, basic frontend): from 4 to 5 weeks. With full UI, animations, statistics, and mobile adaptation: from 8 to 10 weeks. Cost is calculated individually after requirements analysis. Order development — we will prepare a detailed plan. Contact us to discuss your project.







