Blockchain Keno Game Development
Keno is a lottery game: player selects numbers from range (usually 1–80), 20 numbers drawn randomly, winnings depend on matches. Simple mechanics, but blockchain implementation requires solving non-trivial tasks: verifiable randomness for drawing 20 numbers, gas-efficient match checking, correct payout table.
Unlike Crash, Keno is game with fixed outcome before cashout: numbers drawn, result immediately known. This simplifies some aspects but requires special attention to RNG quality.
RNG: Selecting 20 Unique Numbers from 80
Main technical task: from single VRF random seed get 20 unique numbers in range 1–80. Naive approach (rand % 80 repeat 20 times) creates collisions — one number can fall twice.
Fisher-Yates Shuffle for On-Chain Keno
function drawNumbers(uint256 seed) public pure returns (uint8[20] memory drawn) {
// Initialize array 1..80
uint8[80] memory pool;
for (uint8 i = 0; i < 80; i++) {
pool[i] = i + 1;
}
// Fisher-Yates: shuffle first 20 positions
for (uint8 i = 0; i < 20; i++) {
// Get pseudo-random index from seed
uint256 j = uint256(keccak256(abi.encodePacked(seed, i))) % (80 - i);
// Swap pool[i] and pool[i + j]
uint8 temp = pool[i];
pool[i] = pool[i + j];
pool[i + j] = temp;
drawn[i] = pool[i];
}
}
Fisher-Yates guarantees unique numbers without reject-sampling. For on-chain execution: 20 iterations × keccak256 ≈ 80,000–100,000 gas. On Arbitrum ~$0.01. Acceptable.
Alternative — bitmap approach:
function drawNumbersBitmap(uint256 seed) public pure returns (uint8[20] memory drawn) {
uint256 bitmap = 0; // each bit = number 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 approach simpler, but reject-sampling may require more keccak256 calls in worst case (collisions more frequent selecting last numbers). Fisher-Yates preferable for predictable gas.
Match Checking: Gas-Efficient Implementation
Player selected M numbers (1–10), need to count matches with 20 drawn numbers.
Naive approach: nested loops O(M×20) — acceptable for small M.
Bitmap approach for efficiency:
function countMatches(
uint8[] memory playerPicks, // player's numbers
uint8[20] memory drawnNumbers
) public pure returns (uint8 matches) {
// Build bitmap of drawn numbers
uint256 drawnBitmap = 0;
for (uint8 i = 0; i < 20; i++) {
drawnBitmap |= (1 << (drawnNumbers[i] - 1));
}
// Check player's picks against bitmap
for (uint8 i = 0; i < playerPicks.length; i++) {
if (drawnBitmap & (1 << (playerPicks[i] - 1)) != 0) {
matches++;
}
}
}
Bitwise operations faster than nested loops. For typical 1–10 picks: ≈ 3,000–5,000 additional gas.
Payout Table
Keno payouts — most important economic part. Need to balance house edge (usually 20–35% in Keno) with different pick counts.
// Payouts as multiplier of bet (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 verified mathematically: for each picks variant calculate 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 should be ≈ 0.70–0.80 (70–80% RTP, 20–30% house edge)
Full Game Cycle 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");
// Validate picks (1-80, unique)
_validatePicks(picks);
uint256 roundId = nextRoundId++;
rounds[roundId] = KenoRound({
player: msg.sender,
betAmount: msg.value,
playerPicks: picks,
drawnNumbers: [uint8(0),...], // filled in callback
matchCount: 0,
payout: 0,
status: RoundStatus.PENDING,
vrfRequestId: 0
});
// Request VRF
uint256 requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: s_keyHash,
subId: s_subscriptionId,
requestConfirmations: 1,
callbackGasLimit: 300_000, // with reserve for 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];
// Draw 20 numbers
round.drawnNumbers = drawNumbers(randomWords[0]);
// Count matches
round.matchCount = countMatches(round.playerPicks, round.drawnNumbers);
// Calculate payout
uint256 multiplier = payoutTable[round.playerPicks.length][round.matchCount];
round.payout = round.betAmount * multiplier / 100;
round.status = RoundStatus.COMPLETED;
// Pay winner
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
For casino-style with multiple players in one draw round:
Shared draw significantly reduces gas per player.
Stack and Timeline
Chain: Polygon PoS or Arbitrum for low fees. Chainlink VRF V2 Plus. Solidity + Foundry. Frontend: React + wagmi. WebSocket for real-time draw animation.
| Phase | Timeline |
|---|---|
| Contracts (single player, VRF, payout table) | 3–4 weeks |
| Multi-player shared draw | 2 weeks |
| Frontend + draw animation | 2–3 weeks |
| Bankroll + admin panel | 1–2 weeks |
| Audit + testnet | 3–4 weeks |
MVP (single player Keno): 5–7 weeks. Full platform with multi-player draw: 9–12 weeks.







