Keno Game Development on Blockchain
Keno is a lottery game: the player picks numbers from a 1–80 range, the system randomly selects 20 numbers, and winnings depend on the number of matches. The mechanics are simple, but implementing them on a blockchain requires solving several non-trivial problems: verifiable randomness for selecting 20 numbers, gas-efficient match checking, and a mathematically correct payout table. We have been developing such games turnkey for over 5 years and have successfully launched 20+ projects on Ethereum, Polygon, and Arbitrum.
Unlike Crash, Keno is a game with a fixed outcome before cashout: numbers are drawn, and the result is immediately known. This simplifies the architecture but requires special attention to the quality of RNG and bankroll management. Our team helps clients avoid typical mistakes—such as incorrect house edge calculation or gas overruns during drawing. Get in touch for a free consultation on contract architecture.
How to Ensure Randomness in Keno?
The main technical challenge: from a single VRF random seed, obtain 20 unique numbers in the 1–80 range. A naive approach (rand % 80 repeated 20 times) creates collisions—a number can appear twice. We use Fisher-Yates shuffle in a smart contract:
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. On-chain execution: 20 iterations × keccak256 ≈ 80,000–100,000 gas. On Arbitrum this is ~$0.01—acceptable for most scenarios.
Why Fisher-Yates over Bitmap?
A bitmap approach (marking used numbers in a bitmask) is simpler but can require more keccak256 calls when selecting the last numbers due to collisions. Fisher-Yates guarantees a fixed number of iterations—20. For predictable gas, we recommend it, especially for mass draws.
Match Checking: Gas-Efficient Implementation
The player selects M numbers (1–10), and we need to count matches with the 20 drawn numbers. Nested loops O(M×20) are acceptable for small M, but we use a bitmap to save gas:
function countMatches(
uint8[] memory playerPicks,
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 picks against bitmap
for (uint8 i = 0; i < playerPicks.length; i++) {
if (drawnBitmap & (1 << (playerPicks[i] - 1)) != 0) {
matches++;
}
}
}
Bitwise operations are faster than nested loops. For typical 1–10 picks: ~3,000–5,000 additional gas.
Payout Table and House Edge
Keno payouts are the most important economic part. You need to balance the house edge (typically 20–35% in Keno) for different numbers of picks. Here is a snippet of multiplier table for popular variants:
| Number of Picks | Matches | Multiplier (X) |
|---|---|---|
| 1 | 1 | 3.6 |
| 3 | 2 | 2 |
| 3 | 3 | 46 |
| 5 | 3 | 3 |
| 5 | 4 | 12 |
| 5 | 5 | 500 |
| 10 | 5 | 2 |
| 10 | 6 | 18 |
| 10 | 7 | 170 |
| 10 | 8 | 1000 |
| 10 | 9 | 2500 |
| 10 | 10 | 10000 |
The house edge is verified mathematically: for each pick group, Expected Value is calculated:
EV(5 picks) = Σ P(k matches) × payout(5, k) for 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)
For example, for 1 pick: P(1 match) = 20/80 = 0.25, EV = 0.25 × 3.6 = 0.9, i.e., RTP 90%, house edge 10%.
Complete On-Chain Game Cycle
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, // buffer 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
);
}
}
What Is a Shared Draw and How Does It Save Gas?
For a casino-style where multiple players participate in one draw round, we implement a shared draw. One VRF request for the entire round is divided among all participants, reducing the gas per player from ~100,000 to ~15,000.
contract MultiPlayerKeno is VRFConsumerBaseV2Plus {
struct DrawRound {
uint8[20] drawnNumbers;
uint256 drawTime;
bool resolved;
address[] participants;
}
// Rounds every N minutes
uint256 public roundInterval = 3 minutes;
// Bets are tied to a future draw round
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];
}
// One VRF request for the entire draw — shared among all participants
// Gas cost per player: ~15,000 gas (vs ~100,000 for 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;
}
}
How to Verify the Game Result?
- Get the VRF request and response from on-chain events.
- Apply drawNumbers(vrfResult) — get the same 20 numbers.
- Make sure the house didn't manipulate.
Chainlink publicly publishes the cryptographic proof of each VRF response — verification is possible independently of the casino. This mechanism is based on Chainlink VRF.
What's Included in the Work
- Development of the game smart contract (single-player or multi-player)
- Integration of Chainlink VRF V2 Plus
- Configuration of the payout table with mathematical verification of house edge
- Creation of a frontend interface (React + wagmi + RainbowKit)
- Deployment on testnet and mainnet (Polygon, Arbitrum, or another network)
- Smart contract audit (internal or third-party)
- Documentation for integration and support
Indicative Timelines
| Phase | Duration |
|---|---|
| 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 |
Total MVP (single player Keno): 5–7 weeks. Full platform with multi-player draw: 9–12 weeks.
Our team has 5+ years of blockchain development experience and has successfully delivered 20+ gaming smart contracts. If you are planning to launch your own Keno platform with guaranteed transparency and low gas, get a consultation—we will evaluate your project in one day.







