Developing a Wheel of Fortune on Smart Contracts with Chainlink VRF
When developing on-chain games, the most critical point is the source of randomness. If an attacker can predict or influence the outcome, trust is lost. In a Wheel of Fortune on smart contracts, we solve this problem through Chainlink VRF v2.5 — the only oracle with cryptographic proof of the result. The contract requests a random number, and only after proof verification determines the winning sector. Neither the operator nor the miner can influence the outcome — full trustlessness.
This architecture saves up to $10,000 on an audit, as the VRF module has already undergone formal verification by Chainlink. On one of the implemented projects, we reduced audit costs from $25,000 to $15,000 by using the proven VRF.
The house edge parameter is the mathematical advantage of the operator. We calculate it as the ratio of the sum of weighted multipliers to the total weight. For example, a wheel with sectors 2x-50x and MISS gives an RTP of 96.5% (house edge 3.5%). This parameter is hardcoded in the contract and cannot be changed after deployment — players verify the math on Etherscan.
Why Chainlink VRF Is the Only Acceptable Solution?
Any other randomness sources have vulnerabilities:
-
block.timestampandblock.prevrandao— the miner can reject a transaction if the outcome is unfavorable (grinding attack). - On-chain hash of a future block — the operator can refuse to reveal.
- Off-chain oracle without proof — full trust in the operator.
Chainlink VRF v2.5 generates a random number with cryptographic proof that is verified in the contract. Our experience shows: this technology saves up to 80% of audit time (about $10,000), as the algorithm is already verified per Chainlink VRF documentation. Additionally, VRF is 100 times more resistant to manipulation than block.timestamp.
Setting Up a VRF Subscription
A subscription ID is created and funded with LINK tokens. The contract makes requests through this ID. Example integration:
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 {
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;
uint256 requestId;
bool fulfilled;
}
mapping(uint256 => Spin) public spins;
mapping(address => uint256) public pendingSpins;
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];
uint8 sector = _getSector(randomWords[0], s.wheelType);
uint256 payout = _calculatePayout(s.betAmount, sector);
if (payout > 0) {
payable(s.player).transfer(payout);
}
emit SpinResult(s.player, requestId, sector, payout);
}
}
How to Calculate Wheel Sectors and House Edge?
Sectors are defined by weights and multipliers. House edge is configured to the client's requirements — typically from 3% to 10%. Example standard wheel:
struct Sector {
string name;
uint16 weight;
uint16 multiplier;
}
Sector[] standardWheel = [
Sector("2x", 4000, 200),
Sector("3x", 2000, 300),
Sector("5x", 1500, 500),
Sector("10x", 800, 1000),
Sector("20x", 300, 2000),
Sector("50x", 100, 5000),
Sector("MISS", 1300, 0),
];
RTP is calculated as sum(weight * multiplier) / 10000. For this wheel, RTP = 96.5% (house edge 3.5%). The math is verifiable in the contract.
Which Blockchains Are Best for Deployment?
| Network | Gas per spin | VRF time | Liquidity |
|---|---|---|---|
| Ethereum | ~$50-100 | 3-5 blocks | High |
| Arbitrum | ~$0.1-0.5 | 1-3 blocks | Medium |
| Base | ~$0.01-0.1 | 1-2 blocks | Growing |
| Polygon | ~$0.01-0.05 | 1-2 blocks | High |
For high-volume betting games, Base or Polygon are optimal: transaction cost is nearly zero, and VRF arrives in 1-2 blocks. For premium projects with large bets, Ethereum is better, despite high gas — player trust is higher.
What Infrastructure Is Needed for Production?
| Component | Technology |
|---|---|
| Smart contracts | Solidity + Foundry + OpenZeppelin |
| VRF | Chainlink VRF v2.5 |
| Frontend | React + wagmi + viem |
| Animation | Framer Motion / GSAP |
| Events | viem watchContractEvent |
| NFT | ERC-721 (boosts) + ERC-1155 (cosmetics) |
| Deployment | Arbitrum / Base (low gas) |
What Is Included in the Work?
- Sector design and mathematical model (RTP, house edge)
- Smart contracts with VRF integration, LP pool, NFT
- Frontend with wheel animation, VRF waiting, wallet connect
- Testing (unit + integration + fork tests)
- Security audit (we recommend third-party audit)
- Code documentation and deployment instructions
- 1 month support after launch
We have 5+ years of experience in blockchain development and over 10 smart contracts in production. Contact us for a project estimate — request a consultation, timeline and cost are calculated individually.
Work Process
Order development and get:
- Game design (3-5 days) — agreement on sectors, RTP, mechanics.
- Smart contracts (2-3 weeks) — code with tests.
- Frontend (2-3 weeks) — animations, integration.
- Audit and deployment (1 week) — deployment and verification.
Basic version without LP and NFT — from 4 weeks. Full version with LP pool, jackpot, NFT — from 8 weeks. We handle all projects turnkey.
Wheel Animation with VRF
The animation is deterministic from the on-chain result. After receiving the SpinResult event, the frontend spins the wheel to the angle corresponding to the sector. The result is already known; the animation is just visualization.
function animateWheel(sector: number, totalSectors: number, onComplete: () => void) {
const sectorAngle = 360 / totalSectors;
const targetAngle = 360 * 5 + sector * sectorAngle;
wheelElement.style.transition = 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)';
wheelElement.style.transform = `rotate(${targetAngle}deg)`;
setTimeout(onComplete, 4000);
}
VRF waiting time on Ethereum is 3-5 blocks (~36-60 sec). On L2 — 1-3 blocks. We show animation immediately and finalize with result after response.
Additional Mechanics
NFT boosts: ERC-721 tokens provide +10% win, free spin every 24 hours, access to premium wheel. Jackpot: 1-2% of each bet feeds the pool. JACKPOT sector (0.1% probability) takes the whole pool. Psychologically powerful trigger. Daily bonus: free spin with payout limit. Increases retention.
Liquidity pool — users deposit ETH and get a share of house edge profit. Example implementation:
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;
require(houseBalance - amount >= MIN_BANKROLL, "Insufficient bankroll");
lpShares[msg.sender] -= shares;
totalShares -= shares;
houseBalance -= amount;
payable(msg.sender).transfer(amount);
}
Minimum bankroll = MAX_BET * max_multiplier. At 50x and 1 ETH — 50 ETH reserve. Contact us for turnkey development — get a free project estimate. Our team has Solidity certifications and audit experience.







