Разработка игры Keno на блокчейне
Keno — лотерейная игра: игрок выбирает числа из диапазона (обычно 1–80), рандомно тянутся 20 чисел, выигрыш зависит от количества совпадений. Простая механика, но реализация на блокчейне требует решения нескольких нетривиальных задач: verifiable randomness для выбора 20 чисел, gas-эффективная проверка совпадений, правильная таблица выплат.
В отличие от Crash, Keno — игра с фиксированным исходом до cashout: числа тянутся, результат сразу известен. Это упрощает некоторые аспекты, но требует особого внимания к качеству RNG.
RNG: выбор 20 уникальных чисел из 80
Основная техническая задача: из одного VRF random seed нужно получить 20 уникальных чисел в диапазоне 1–80. Наивный подход (rand % 80 повторить 20 раз) создаёт коллизии — одно число может выпасть дважды.
Fisher-Yates shuffle для on-chain Keno
function drawNumbers(uint256 seed) public pure returns (uint8[20] memory drawn) {
// Инициализируем массив 1..80
uint8[80] memory pool;
for (uint8 i = 0; i < 80; i++) {
pool[i] = i + 1;
}
// Fisher-Yates: тасуем первые 20 позиций
for (uint8 i = 0; i < 20; i++) {
// Получаем псевдо-random индекс из seed
uint256 j = uint256(keccak256(abi.encodePacked(seed, i))) % (80 - i);
// Swap pool[i] и pool[i + j]
uint8 temp = pool[i];
pool[i] = pool[i + j];
pool[i + j] = temp;
drawn[i] = pool[i];
}
}
Fisher-Yates даёт гарантированно уникальные числа без reject-sampling. Для on-chain выполнения: 20 итераций × keccak256 ≈ 80,000–100,000 gas. На Arbitrum это ~$0.01. Приемлемо.
Альтернатива — bitmap approach:
function drawNumbersBitmap(uint256 seed) public pure returns (uint8[20] memory drawn) {
uint256 bitmap = 0; // каждый бит = число 1-80
uint8 count = 0;
uint256 nonce = 0;
while (count < 20) {
uint8 num = uint8(uint256(keccak256(abi.encodePacked(seed, nonce))) % 80) + 1;
nonce++;
if (bitmap & (1 << (num - 1)) == 0) {
bitmap |= (1 << (num - 1));
drawn[count] = num;
count++;
}
}
}
Bitmap подход проще, но reject-sampling может потребовать больше keccak256 вызовов в худшем случае (при выборе последних чисел коллизии чаще). Fisher-Yates предпочтительнее для предсказуемого gas.
Проверка совпадений: gas-эффективная реализация
Игрок выбрал M чисел (1–10), нужно подсчитать сколько совпадает с 20 drawn numbers.
Наивный подход: вложенные циклы O(M×20) — допустимо для small M.
Bitmap подход для эффективности:
function countMatches(
uint8[] memory playerPicks, // числа игрока
uint8[20] memory drawnNumbers
) public pure returns (uint8 matches) {
// Строим bitmap drawn numbers
uint256 drawnBitmap = 0;
for (uint8 i = 0; i < 20; i++) {
drawnBitmap |= (1 << (drawnNumbers[i] - 1));
}
// Проверяем picks игрока против bitmap
for (uint8 i = 0; i < playerPicks.length; i++) {
if (drawnBitmap & (1 << (playerPicks[i] - 1)) != 0) {
matches++;
}
}
}
Битовые операции быстрее nested loops. Для типичных 1–10 picks: ≈ 3,000–5,000 дополнительного gas.
Таблица выплат
Keno выплаты — самая важная экономическая часть. Нужно балансировать house edge (обычно 20–35% в Keno) при разных количествах picks.
// Выплаты как множитель ставки (basis points: 100 = 1x)
// [picks][matches] → multiplier
uint256[11][11] public payoutTable;
constructor() {
// 1 pick
payoutTable[1][0] = 0;
payoutTable[1][1] = 360; // 3.6x
// 3 picks
payoutTable[3][0] = 0;
payoutTable[3][1] = 0;
payoutTable[3][2] = 200; // 2x
payoutTable[3][3] = 4600; // 46x
// 5 picks
payoutTable[5][0] = 0;
payoutTable[5][1] = 0;
payoutTable[5][2] = 0;
payoutTable[5][3] = 300; // 3x
payoutTable[5][4] = 1200; // 12x
payoutTable[5][5] = 50000; // 500x
// 10 picks
payoutTable[10][0] = 0;
payoutTable[10][5] = 200; // 2x
payoutTable[10][6] = 1800; // 18x
payoutTable[10][7] = 17000; // 170x
payoutTable[10][8] = 100000; // 1000x
payoutTable[10][9] = 250000; // 2500x
payoutTable[10][10] = 1000000; // 10000x (jackpot)
}
House edge верифицируется математически: для каждого варианта picks рассчитывается Expected Value:
EV(5 picks) = Σ P(k matches) × payout(5, k) для k = 0..5
P(k matches) = C(20,k) × C(60, 5-k) / C(80, 5)
EV должен быть ≈ 0.70–0.80 (70–80% RTP, 20–30% house edge)
Полный игровой цикл on-chain
contract KenoGame is VRFConsumerBaseV2Plus {
struct KenoRound {
address player;
uint256 betAmount;
uint8[] playerPicks;
uint8[20] drawnNumbers;
uint8 matchCount;
uint256 payout;
RoundStatus status;
uint256 vrfRequestId;
}
mapping(uint256 => KenoRound) public rounds;
mapping(uint256 => uint256) public vrfToRound;
uint256 public nextRoundId;
function playKeno(uint8[] calldata picks) external payable {
require(picks.length >= 1 && picks.length <= 10, "Invalid picks count");
require(msg.value >= MIN_BET && msg.value <= maxBet(), "Invalid bet");
// Валидируем picks (1-80, уникальные)
_validatePicks(picks);
uint256 roundId = nextRoundId++;
rounds[roundId] = KenoRound({
player: msg.sender,
betAmount: msg.value,
playerPicks: picks,
drawnNumbers: [uint8(0),...], // заполнится в callback
matchCount: 0,
payout: 0,
status: RoundStatus.PENDING,
vrfRequestId: 0
});
// Запрашиваем VRF
uint256 requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: s_keyHash,
subId: s_subscriptionId,
requestConfirmations: 1,
callbackGasLimit: 300_000, // с запасом для drawNumbers
numWords: 1,
extraArgs: ""
})
);
rounds[roundId].vrfRequestId = requestId;
vrfToRound[requestId] = roundId;
emit KenoRoundStarted(roundId, msg.sender, picks, msg.value);
}
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
uint256 roundId = vrfToRound[requestId];
KenoRound storage round = rounds[roundId];
// Вытягиваем 20 чисел
round.drawnNumbers = drawNumbers(randomWords[0]);
// Считаем совпадения
round.matchCount = countMatches(round.playerPicks, round.drawnNumbers);
// Рассчитываем выплату
uint256 multiplier = payoutTable[round.playerPicks.length][round.matchCount];
round.payout = round.betAmount * multiplier / 100;
round.status = RoundStatus.COMPLETED;
// Выплачиваем победителю
if (round.payout > 0) {
require(address(this).balance >= round.payout, "Insufficient bankroll");
payable(round.player).transfer(round.payout);
}
emit KenoResult(
roundId,
round.player,
round.drawnNumbers,
round.matchCount,
round.payout
);
}
}
Multi-player Keno: shared draw
Для казино-стиля где несколько игроков участвуют в одном draw раунда:
contract MultiPlayerKeno is VRFConsumerBaseV2Plus {
struct DrawRound {
uint8[20] drawnNumbers;
uint256 drawTime;
bool resolved;
address[] participants;
}
// Раунды каждые N минут
uint256 public roundInterval = 3 minutes;
// Ставки привязываются к будущему draw раунду
struct PlayerBet {
uint8[] picks;
uint256 amount;
uint256 drawRoundId;
}
function getBetsOnNextDraw(address player) external view returns (PlayerBet[] memory) {
uint256 nextDraw = (block.timestamp / roundInterval + 1) * roundInterval;
return pendingBets[nextDraw][player];
}
// Один VRF запрос на весь draw — делится между всеми участниками
// Gas cost per player: ~15,000 gas (vs ~100,000 для single player)
function triggerDraw(uint256 drawRoundId) external {
require(block.timestamp >= drawRoundId, "Too early");
require(!drawRounds[drawRoundId].resolved, "Already drawn");
uint256 requestId = s_vrfCoordinator.requestRandomWords(...);
vrfToDrawRound[requestId] = drawRoundId;
}
}
Shared draw значительно снижает gas на игрока.
Верификация и прозрачность
После каждого раунда игрок может верифицировать результат:
- Получить VRF request и response из on-chain событий
- Применить
drawNumbers(vrfResult)— получить те же 20 чисел - Убедиться что house не манипулировал
Chainlink публично публикует cryptographic proof каждого VRF ответа — верификация возможна независимо от казино.
Стек и ориентиры
Chain: Polygon PoS или Arbitrum для низких fees. Chainlink VRF V2 Plus для randomness. Solidity + Foundry. Frontend: React + wagmi. WebSocket для real-time draw animation.
Тестирование: mock VRF Coordinator от Chainlink для unit тестов, mainnet fork + реальный VRF для integration.
| Фаза | Срок |
|---|---|
| Контракты (single player, VRF, payout table) | 3–4 нед |
| Multi-player shared draw | 2 нед |
| Frontend + draw animation | 2–3 нед |
| Bankroll + admin panel | 1–2 нед |
| Audit + тестнет | 3–4 нед |
Итого MVP (single player Keno): 5–7 недель. Полная платформа с multi-player draw: 9–12 недель.







