Разработка игры Keno на блокчейне

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска DeFi-протоколов, NFT-маркетплейсов и криптобирж. Аудит безопасности, токеномика, интеграция с существующей инфраструктурой.
Показано 1 из 1 услугВсе 1306 услуг
Разработка игры Keno на блокчейне
Средняя
~3-5 рабочих дней
Часто задаваемые вопросы
Направления блокчейн-разработки
Этапы блокчейн-разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1258
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1170
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    873
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1092
  • image_logo-advance_0.png
    Разработка логотипа компании B2B Advance
    563
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    830

Разработка игры 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 на игрока.

Верификация и прозрачность

После каждого раунда игрок может верифицировать результат:

  1. Получить VRF request и response из on-chain событий
  2. Применить drawNumbers(vrfResult) — получить те же 20 чисел
  3. Убедиться что 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 недель.