Blockchain Wheel of Fortune Game Development
Wheel of Fortune is one of the simplest gambling mechanisms: a player bets, the wheel spins, a sector lands, payout calculated by coefficient. The essence comes down to one question: who generates the random number and can it be trusted? Without fair randomization, blockchain wheel of fortune is just a beautiful interface wrapping fraud.
This makes Chainlink VRF (Verifiable Random Function) a central technical element. Not optional — mandatory. Any other solution is either predictable or subject to operator manipulation.
Why Standard Randomness Sources Don't Work
block.timestamp, block.prevrandao — controlled by validators. A miner can choose not to include a transaction if they see an unfavorable outcome (grinding attack). For high stakes, this is a direct exploitation vector.
On-chain hash of future block — same problem. A malicious operator sees the hash, can cancel reveal if outcome is unfavorable.
Off-chain oracle without proof — complete trust in operator. User cannot verify that the number wasn't chosen retroactively.
Chainlink VRF v2.5 — the only production-ready solution: random number generated with cryptographic proof, verified on-chain. Operator physically cannot manipulate the result.
Chainlink VRF Integration
Subscription-based approach
VRF v2.5 works via subscription: create subscription, fund with LINK tokens, contract makes requests through subscription ID.
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
contract WheelOfFortune is VRFConsumerBaseV2Plus {
// Chainlink VRF parameters (Ethereum mainnet)
bytes32 constant KEY_HASH = 0x9fe0eebf5e446e3c998ec9bb19951541aee00bb90ea201ae456421a2ded86805;
uint256 immutable subscriptionId;
uint32 constant CALLBACK_GAS_LIMIT = 100_000;
uint16 constant REQUEST_CONFIRMATIONS = 3;
struct Spin {
address player;
uint256 betAmount;
uint8 wheelType; // 0=standard, 1=premium (different sector sets)
uint256 requestId;
bool fulfilled;
}
mapping(uint256 => Spin) public spins; // requestId => Spin
mapping(address => uint256) public pendingSpins; // player => requestId
event SpinRequested(address indexed player, uint256 indexed requestId, uint256 betAmount);
event SpinResult(address indexed player, uint256 indexed requestId, uint8 sector, uint256 payout);
function spin(uint8 wheelType) external payable {
require(msg.value >= MIN_BET && msg.value <= MAX_BET, "Invalid bet");
require(pendingSpins[msg.sender] == 0, "Spin pending");
uint256 requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: KEY_HASH,
subId: subscriptionId,
requestConfirmations: REQUEST_CONFIRMATIONS,
callbackGasLimit: CALLBACK_GAS_LIMIT,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
spins[requestId] = Spin({
player: msg.sender,
betAmount: msg.value,
wheelType: wheelType,
requestId: requestId,
fulfilled: false
});
pendingSpins[msg.sender] = requestId;
emit SpinRequested(msg.sender, requestId, msg.value);
}
function fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords)
internal override {
Spin storage s = spins[requestId];
require(!s.fulfilled, "Already fulfilled");
s.fulfilled = true;
delete pendingSpins[s.player];
// Determine wheel sector
uint8 sector = _getSector(randomWords[0], s.wheelType);
uint256 payout = _calculatePayout(s.betAmount, sector);
// Payout
if (payout > 0) {
payable(s.player).transfer(payout);
}
emit SpinResult(s.player, requestId, sector, payout);
}
}
Wheel sector design
Sectors determine house edge and excitement. Important: final RTP (Return to Player) must be explicitly stated and verifiable on-chain.
struct Sector {
string name;
uint16 weight; // out of 10000 (basis points)
uint16 multiplier; // multiplier x100 (200 = 2x, 500 = 5x, 0 = lose)
}
// Standard wheel — sum of weight = 10000
Sector[] standardWheel = [
Sector("2x", 4000, 200), // 40% chance, 2x
Sector("3x", 2000, 300), // 20% chance, 3x
Sector("5x", 1500, 500), // 15% chance, 5x
Sector("10x", 800, 1000), // 8% chance, 10x
Sector("20x", 300, 2000), // 3% chance, 20x
Sector("50x", 100, 5000), // 1% chance, 50x
Sector("MISS", 1300, 0), // 13% chance, loss
];
// RTP = sum(weight * multiplier / 100) / 10000
// = (4000*2 + 2000*3 + 1500*5 + 800*10 + 300*20 + 100*50 + 1300*0) / 10000
// = (8000 + 6000 + 7500 + 8000 + 6000 + 5000 + 0) / 10000 = 40500/10000 = 4.05...
// Correct: RTP = sum(weight/10000 * multiplier/100)
// = 0.4*2 + 0.2*3 + 0.15*5 + 0.08*10 + 0.03*20 + 0.01*50 = 0.8+0.6+0.75+0.8+0.6+0.5 = 4.05
// House edge = 1 - RTP = ... need normalization to multiplier → bet
// RTP = 0.4*2 + 0.2*3 + 0.15*5 + 0.08*10 + 0.03*20 + 0.01*50 + 0.13*0 = 4.05?
// Here multiplier = outgoing / bet. RTP as bet fraction = same numbers.
// For house edge < 1.0: adjust weights for desired RTP (typically 90-97%)
function _getSector(uint256 randomWord, uint8 wheelType) internal view returns (uint8) {
Sector[] storage wheel = wheelType == 0 ? standardWheel : premiumWheel;
uint256 position = randomWord % 10000;
uint256 cumulative = 0;
for (uint8 i = 0; i < wheel.length; i++) {
cumulative += wheel[i].weight;
if (position < cumulative) return i;
}
return uint8(wheel.length - 1);
}
House Bankroll and Liquidity
Contract must maintain balance to pay maximum possible payout. Minimum bankroll = MAX_BET × max_multiplier. At 50x multiplier and MAX_BET 1 ETH — minimum 50 ETH reserve.
Liquidity provider model. Users deposit ETH into pool as LP, earn share of house edge profits. Solves bankroll problem and creates yield-bearing product:
mapping(address => uint256) public lpShares;
uint256 public totalShares;
uint256 public houseBalance;
function addLiquidity() external payable {
uint256 shares = totalShares == 0
? msg.value
: (msg.value * totalShares) / houseBalance;
lpShares[msg.sender] += shares;
totalShares += shares;
houseBalance += msg.value;
}
function removeLiquidity(uint256 shares) external {
require(lpShares[msg.sender] >= shares, "Insufficient shares");
uint256 amount = (shares * houseBalance) / totalShares;
// Check sufficient liquidity remains after withdrawal
require(houseBalance - amount >= MIN_BANKROLL, "Insufficient bankroll");
lpShares[msg.sender] -= shares;
totalShares -= shares;
houseBalance -= amount;
payable(msg.sender).transfer(amount);
}
Frontend: Animation and UX
Wheel visual animation must be deterministic from VRF result — not random on frontend. Important for fairness perception: result already determined on-chain, animation only visualizes it.
// After receiving SpinResult event
function animateWheel(sector: number, totalSectors: number, onComplete: () => void) {
const sectorAngle = 360 / totalSectors
const targetAngle = 360 * 5 + sector * sectorAngle // 5 full rotations + target sector
wheelElement.style.transition = 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)'
wheelElement.style.transform = `rotate(${targetAngle}deg)`
setTimeout(onComplete, 4000)
}
Waiting for VRF response. VRF takes 3-5 blocks (~36-60 seconds on Ethereum). User sees spinning animation + timer. On L2 (Arbitrum, Base) — faster, 1-3 blocks. Polygon — even faster.
For instant feel: show "spinning" animation immediately, wait for VRF response, play final spin revealing result.
NFT Boosts and Game Mechanics
Spin boost NFT. NFTs grant additional multiplier on winnings (+10%), extra spin once per 24h, access to premium wheel with higher multipliers. Creates secondary market and token sink.
Jackpot mechanic. Small percentage of each bet (1-2%) goes to jackpot pool. Landing special "JACKPOT" sector (very small weight, 0.1%) takes entire pool. Psychologically attractive mechanism.
Daily bonus spin. Free spin once per 24 hours with limited max payout. Increases retention without significant house impact.
Stack and Infrastructure
| Component | Technology |
|---|---|
| Smart contracts | Solidity + Foundry + OpenZeppelin |
| VRF | Chainlink VRF v2.5 |
| Frontend | React + wagmi + viem |
| Animation | Framer Motion / GSAP |
| Events monitoring | viem watchContractEvent |
| NFT | ERC-721 (boosts) + ERC-1155 (cosmetics) |
| Deploy | Arbitrum / Base (low gas, fast blocks) |
Development Process
Game design (3-5 days). Sector design, RTP and house edge calculation, LP model, NFT mechanics. Math verification before coding.
Smart contracts (2-3 weeks). VRF integration, wheel logic, LP mechanism, NFT contracts. Foundry tests with mocked VRF coordinator.
Frontend (2-3 weeks). Wheel visualization, animations, VRF waiting, wallet integration, LP dashboard.
Audit. VRF integration and LP mechanism — mandatory audit. Special attention: can operator change sectors without timelock, correct bankroll checks.
Basic version without LP and NFT — 4-5 weeks. Full with LP pool, jackpot, NFT system — 8-10 weeks.







