Blockchain Roulette Game Development
Roulette on blockchain—classic provably fair gambling use case. Main engineering task: ensure outcome unpredictability no one can manipulate—neither casino nor player. Solution—verifiable randomness via Chainlink VRF or commit-reveal scheme.
Randomness: Main Problem
Can't use block.timestamp, block.prevrandao, or block hashes as randomness source. Miner/validator can influence these—miner extractable value (MEV) attack on randomness. For roulette: node can choose to include or exclude transaction depending on winning block.
Chainlink VRF v2.5
Cryptographically secure, verifiable random from decentralized oracle network:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract RouletteGame is VRFConsumerBaseV2Plus {
uint256 public subscriptionId;
bytes32 public keyHash;
enum BetType { Number, Red, Black, Even, Odd, Low, High }
struct Bet {
address player;
BetType betType;
uint8 number; // for Number bet (0-36)
uint256 amount;
bool settled;
}
struct Round {
uint256 requestId;
uint8 result; // 0-36
bool fulfilled;
mapping(uint256 => Bet) bets;
uint256 betCount;
}
mapping(uint256 => Round) public rounds;
mapping(uint256 => uint256) public requestToRound;
uint256 public currentRoundId;
uint256 public constant MAX_BET = 1 ether;
uint256 public constant HOUSE_EDGE = 270; // 2.7%
event BetPlaced(uint256 roundId, address player, BetType betType, uint8 number, uint256 amount);
event RoundSettled(uint256 roundId, uint8 result);
event WinningPaid(address player, uint256 amount);
function placeBet(
BetType betType,
uint8 number
) external payable {
require(msg.value > 0 && msg.value <= MAX_BET, "Invalid bet amount");
if (betType == BetType.Number) {
require(number <= 36, "Invalid number");
}
Round storage round = rounds[currentRoundId];
uint256 betId = round.betCount++;
round.bets[betId] = Bet({
player: msg.sender,
betType: betType,
number: number,
amount: msg.value,
settled: false
});
emit BetPlaced(currentRoundId, msg.sender, betType, number, msg.value);
}
// Close round and request randomness
function spinWheel() external returns (uint256 requestId) {
requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: keyHash,
subId: subscriptionId,
requestConfirmations: 3,
callbackGasLimit: 300_000,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({ nativePayment: false })
)
})
);
requestToRound[requestId] = currentRoundId;
rounds[currentRoundId].requestId = requestId;
currentRoundId++;
emit RoundStarted(currentRoundId - 1, requestId);
}
// Callback from Chainlink VRF
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
uint256 roundId = requestToRound[requestId];
Round storage round = rounds[roundId];
// 0-36 inclusive = 37 values
uint8 result = uint8(randomWords[0] % 37);
round.result = result;
round.fulfilled = true;
emit RoundSettled(roundId, result);
_settleAllBets(roundId);
}
function _settleAllBets(uint256 roundId) internal {
Round storage round = rounds[roundId];
uint8 result = round.result;
for (uint256 i = 0; i < round.betCount; i++) {
Bet storage bet = round.bets[i];
if (bet.settled) continue;
bet.settled = true;
uint256 payout = _calculatePayout(bet, result);
if (payout > 0) {
payable(bet.player).transfer(payout);
emit WinningPaid(bet.player, payout);
}
}
}
function _calculatePayout(
Bet memory bet,
uint8 result
) internal pure returns (uint256) {
bool win = false;
uint256 multiplier = 0;
if (bet.betType == BetType.Number) {
win = (bet.number == result);
multiplier = 35; // 35:1
} else if (bet.betType == BetType.Red) {
win = _isRed(result);
multiplier = 1;
} else if (bet.betType == BetType.Black) {
win = (!_isRed(result) && result != 0);
multiplier = 1;
} else if (bet.betType == BetType.Even) {
win = (result != 0 && result % 2 == 0);
multiplier = 1;
} else if (bet.betType == BetType.Odd) {
win = (result % 2 == 1);
multiplier = 1;
} else if (bet.betType == BetType.Low) {
win = (result >= 1 && result <= 18);
multiplier = 1;
} else if (bet.betType == BetType.High) {
win = (result >= 19 && result <= 36);
multiplier = 1;
}
if (!win) return 0;
return bet.amount + (bet.amount * multiplier);
}
// Red numbers on European roulette
function _isRed(uint8 n) internal pure returns (bool) {
uint256 redNumbers = 0x3A4A5251412C2B1A191009080706;
return (redNumbers >> n) & 1 == 1;
}
receive() external payable {}
function withdrawHouseBalance(uint256 amount) external onlyOwner {
payable(owner()).transfer(amount);
}
}
Frontend and Animation
Roulette wheel—DOM canvas (React + Pixi.js or Three.js). Key points:
- Wheel animation starts on
spinWheel()call, before VRF result arrives - Real result comes in ~30–60 seconds (VRF callback time)
- Wheel spins several full rotations, then smoothly stops on correct sector
-
RoundSettledevent triggers final position animation
const unwatch = publicClient.watchContractEvent({
address: ROULETTE_ADDRESS,
abi: rouletteAbi,
eventName: "RoundSettled",
args: { roundId: currentRoundId },
onLogs: (logs) => {
const result = logs[0].args.result;
rouletteWheel.stopAt(result);
showResult(result);
checkWinnings(result);
},
});
Network and Cost
Main parameter: VRF callback cost. Expensive on Ethereum mainnet. Recommended networks:
| Network | VRF Cost | Response Time | Recommendation |
|---|---|---|---|
| Arbitrum | ~$0.30–0.80 | ~30–60 sec | Optimal |
| Polygon | ~$0.01–0.05 | ~30–60 sec | Budget |
| Avalanche | ~$0.10–0.30 | ~30–60 sec | Good |
| Base | ~$0.05–0.20 | ~30–60 sec | Growing |
For high-frequency spins (several per minute)—consider alternative VRF: Pyth Entropy (significantly cheaper), or commit-reveal (instant but less decentralized).







