Розробка гри Wheel of Fortune на блокчейні
Колесо Фортуни — один з найпростіших механізмів азартних ігор: гравець ставить, рулетка крутиться, випадає сектор, виплата за коефіцієнтом. Вся суть зводиться до одного питання: хто генерує випадкове число й чи можна цьому довіряти? Без чесної рандомізації blockchain wheel of fortune — просто гарний інтерфейс навколо шахрайства.
Це робить Chainlink VRF (Verifiable Random Function) центральним технічним елементом. Не факультативно — обов'язково. Будь-яке інше рішення або передбачуване, або залежить від маніпуляції оператора.
Чому стандартні джерела рандома не працюють
block.timestamp, block.prevrandao — контролюються валідаторами. Майнер може вибрати не включати транзакцію, якщо бачить несприятливий результат (grinding attack). Для високих ставок це прямий вектор експлуатації.
On-chain хеш майбутнього блоку — аналогічна проблема. Нечесний оператор бачить хеш, може скасувати reveal якщо результат несприятливий.
Off-chain оракул без proof — повна довіра до оператора. Користувач не може перевірити, що число не було обрано постфактум.
Chainlink VRF v2.5 — єдине готове до production рішення: випадкове число генерується з криптографічним proof, верифікованим on-chain. Оператор фізично не може маніпулювати результатом.
Інтеграція Chainlink VRF
Підхід на основі підписки
VRF v2.5 працює через підписку: створюєш підписку, поповнюєш LINK токенами, контракт робить запити через 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 (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 (різні набори секторів)
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];
// Визначаємо сектор колеса
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);
}
}
Дизайн секторів колеса
Сектори визначають house edge й excitement. Важливо: підсумковий RTP (Return to Player) повинен бути явно заявлений і верифіковний on-chain.
struct Sector {
string name;
uint16 weight; // з 10000 (basis points)
uint16 multiplier; // множник x100 (200 = 2x, 500 = 5x, 0 = lose)
}
// Стандартне колесо — сума weight = 10000
Sector[] standardWheel = [
Sector("2x", 4000, 200), // 40% шанс, 2x
Sector("3x", 2000, 300), // 20% шанс, 3x
Sector("5x", 1500, 500), // 15% шанс, 5x
Sector("10x", 800, 1000), // 8% шанс, 10x
Sector("20x", 300, 2000), // 3% шанс, 20x
Sector("50x", 100, 5000), // 1% шанс, 50x
Sector("MISS", 1300, 0), // 13% шанс, програш
];
// 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...
// Правильно: 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 = ... потрібна нормалізація множника до ставки
// RTP = 0.4*2 + 0.2*3 + 0.15*5 + 0.08*10 + 0.03*20 + 0.01*50 + 0.13*0 = 4.05?
// Тут множник = outgoing / bet. RTP як доля ставки = ті ж цифри.
// Для house edge < 1.0: підбираємо ваги під бажаний RTP (зазвичай 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 й ліквідність
Контракт повинен мати баланс для виплати максимального можливого виграшу. Мінімальний bankroll = MAX_BET × max_multiplier. При 50x множнику й MAX_BET 1 ETH — мінімум 50 ETH резерву.
Модель Liquidity provider. Користувачі вносять ETH у пул як LP, отримують частку house edge прибутку. Вирішує проблему bankroll й створює yield-bearing продукт:
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);
}
Фронтенд: анімація й UX
Візуальна анімація колеса повинна бути детерміністичною від результату VRF — не випадковою на фронтенді. Важливо для сприйняття справедливості: результат уже визначений on-chain, анімація тільки візуалізує його.
// Після отримання SpinResult події
function animateWheel(sector: number, totalSectors: number, onComplete: () => void) {
const sectorAngle = 360 / totalSectors
const targetAngle = 360 * 5 + sector * sectorAngle // 5 повних обертів + цільовий сектор
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 відповіді. VRF займає 3-5 блоків (~36-60 секунд на Ethereum). Користувач бачить крутяння анімації + таймер. На L2 (Arbitrum, Base) — швидше, 1-3 блоки. Polygon — ще швидше.
Для миттєвого відчуття: показуємо анімацію «крутимо» відразу, чекаємо VRF відповіді, програємо фінальний spin з розкриттям результату.
NFT бусти й ігрові механіки
Spin boost NFT. NFT дають додатковий множник на виграш (+10%), додатковий spin раз на 24 години, доступ до premium колеса з вищими множниками. Створює вторинний ринок й token sink.
Jackpot механіка. Невелика частка кожної ставки (1-2%) йде в jackpot пул. Випадення спеціального сектора «JACKPOT» (дуже малий вік, 0.1%) забирає весь пул. Психологічно привабливий механізм.
Daily bonus spin. Безплатний spin раз на 24 години з обмеженим max виграшем. Підвищує retention без значного впливу на house баланс.
Стек й інфраструктура
| Компонент | Технологія |
|---|---|
| Смарт контракти | Solidity + Foundry + OpenZeppelin |
| VRF | Chainlink VRF v2.5 |
| Фронтенд | React + wagmi + viem |
| Анімація | Framer Motion / GSAP |
| Events моніторинг | viem watchContractEvent |
| NFT | ERC-721 (бусти) + ERC-1155 (cosmetics) |
| Deploy | Arbitrum / Base (низька комісія газу, швидкі блоки) |
Процес розробки
Game design (3-5 днів). Дизайн секторів, розрахунок RTP й house edge, LP модель, NFT механіки. Верифікація математики перед кодуванням.
Смарт контракти (2-3 тижні). VRF інтеграція, wheel логіка, LP механізм, NFT контракти. Foundry тести з мокнутим VRF coordinator.
Фронтенд (2-3 тижні). Візуалізація колеса, анімації, очікування VRF, wallet інтеграція, LP dashboard.
Аудит. VRF інтеграція й LP механізм — обов'язковий аудит. Особливу увагу: чи може оператор змінювати сектори без timelock, коректність перевірок bankroll.
Базова версія без LP й NFT — 4-5 тижнів. Повна з LP пулом, jackpot, NFT системою — 8-10 тижнів.







