Разработка игры Blackjack на блокчейне
Блокчейн Blackjack — одна из наиболее технически интересных задач в on-chain gaming. Проблема в следующем: карты нужно сдавать случайно и честно, но при этом игрок не должен видеть карты до их «открытия». В off-chain казино это тривиально — сервер знает все карты, показывает их поэтапно. On-chain всё состояние контракта публично, а блокчейн рандом манипулируем.
Решение — commit-reveal схема в связке с Chainlink VRF, или mental poker протокол. Разберём оба подхода.
Честный рандом: Chainlink VRF
Chainlink VRF даёт верифицируемую случайность: число генерируется off-chain с криптографическим доказательством, которое проверяется on-chain. Никто — ни игрок, ни оператор — не может предсказать результат.
Флоу для Blackjack:
- Игрок делает ставку, контракт запрашивает VRF
- VRF fulfillment (через callback) — контракт получает random, генерирует deck или первые карты
- Игрок решает: hit или stand
- Если hit — новый VRF запрос для следующей карты
Проблема с "если hit": каждый VRF запрос — это задержка (1-3 блока) и дополнительный LINK. Для реалтайм игры некомфортно.
Оптимизация: запросить весь deck сразу
contract Blackjack is VRFConsumerBaseV2Plus {
struct Game {
address player;
uint256 bet;
uint8[] deck; // все 52 карты в зашифрованном порядке
uint8 playerIdx; // текущий индекс в деке
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, // одно большое число для 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, // дилер берёт карты с позиции 4
active: false // станет true после fulfillment
});
playerGame[msg.sender] = requestId;
}
function fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) internal override {
Game storage game = games[requestId];
// Fisher-Yates shuffle детерминированный из одного 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]);
}
// Первые 4 карты сразу сдаются: 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]); // видимые карты игрока
// deck[1] и deck[3] - карты дилера, deck[1] скрыта до конца
}
}
После fulfillment deck зашафлен и зафиксирован. Следующие ходы (hit) берут карты из уже сгенерированного deck — без новых VRF запросов. Быстро и дёшево.
Проблема: deck виден on-chain
Но весь deck хранится в game.deck — публично! Технически игрок может прочитать все будущие карты из storage.
Решение: хранить только seed, карты вычислять детерминированно только когда они «открываются»:
// Не храним deck, только seed
mapping(uint256 => uint256) private gameSeeds;
function getCard(uint256 gameId, uint8 position) private view returns (uint8) {
// Детерминированно вычисляем карту из seed и позиции
// Карта не в storage — нельзя прочитать заранее
return uint8(uint256(keccak256(abi.encodePacked(gameSeeds[gameId], position))) % 52);
}
Это не полностью решает проблему: технически можно симулировать getCard для всех позиций в том же блоке. Полное решение — Mental Poker протокол с шифрованием каждой карты, но это значительно сложнее.
Логика Blackjack on-chain
Значение карт: туз = 1 или 11, картинки = 10, остальные по номиналу:
function cardValue(uint8 card) internal pure returns (uint8) {
uint8 rank = card % 13; // 0-12: туз, 2-10, валет, дама, король
if (rank == 0) return 11; // туз (мягкое значение)
if (rank >= 10) return 10; // картинки
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;
}
// Мягкий туз становится жёстким (1) если bust
while (score > 21 && aces > 0) {
score -= 10;
aces--;
}
return score;
}
Логика дилера on-chain: дилер берёт карты пока score < 17, останавливается на 17+ (включая soft 17 в зависимости от правил).
Управление ликвидностью: банкролл
Контракт должен иметь ETH для выплат. House edge в Blackjack ~0.5% при оптимальной стратегии — это реальная маржа. Но дисперсия высокая, нужен достаточный bankroll.
Kelly Criterion для максимальной ставки: при edge e и bankroll B, максимальная ставка ≈ B * e / variance. Для Blackjack с edge 0.5% и дисперсией ~1.3 — максимальная ставка ≈ 0.38% от bankroll. На практике: ограничить максимальную ставку на уровне 1-2% 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 и UX
Блокчейн Blackjack требует тщательного UX для асинхронности. VRF fulfillment — не мгновенный. Игрок нажал "Deal" — ждёт 1-3 блока до появления карт.
Флоу в UI:
- Ставка →
startGame()→ статус "Dealing..." (ждём событиеGameStarted) - Карты появляются → игрок видит свои 2 карты, одну карту дилера
- Hit/Stand → мгновенные, из зашафленного deck
- Stand → дилер открывает карты → итог → выплата
Для polling событий: wagmi с useWatchContractEvent или WebSocket подключение к ноде.
Стек
| Компонент | Технология |
|---|---|
| Smart contract | Solidity 0.8.x + VRF v2.5 |
| Тестирование | Foundry + VRF mock |
| Frontend | React + wagmi + viem |
| Сеть | Polygon / Arbitrum (низкий gas) |
| Аудит | Обязателен (gambling + custody средств) |
Аудит критически важен — контракт хранит ETH и выплачивает выигрыши. Ошибки в логике выплат или in random seed generation — прямая потеря средств.
Сроки
Рабочий on-chain Blackjack (смарт-контракт, тесты, базовый frontend): 4-5 недель. С полным UI, анимациями, статистикой и мобильной адаптацией — 8-10 недель.







