Разработка игры Wheel of Fortune на блокчейне
Колесо фортуны — один из простейших gambling механизмов: игрок ставит, рулетка крутится, выпадает сектор, выплата по коэффициенту. Вся суть в одном вопросе: кто генерирует случайное число и можно ли этому доверять? Без честной рандомизации blockchain wheel of fortune — просто красивый интерфейс вокруг мошенничества.
Это делает Chainlink VRF (Verifiable Random Function) центральным техническим элементом. Не опциональным — обязательным. Любое другое решение либо предсказуемо, либо поддаётся манипуляции оператором.
Почему стандартные источники рандома не работают
block.timestamp, block.prevrandao — управляются валидаторами. Майнер может выбрать не включать транзакцию, если видит невыгодный outcome (grinding attack). Для высоких ставок это прямой вектор эксплуатации.
On-chain хэш будущего блока — аналогичная проблема. Мошеннический оператор видит хэш, может отменить reveal если результат невыгоден.
Off-chain оракул без proof — полное доверие оператору. Пользователь не может верифицировать, что число не было выбрано постфактум.
Chainlink VRF v2.5 — единственное production-ready решение: случайное число генерируется с cryptographic proof, который верифицируется on-chain. Оператор физически не может манипулировать результатом.
Chainlink VRF интеграция
Subscription-based подход
VRF v2.5 работает через subscription: создаёшь подписку, пополняешь 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 = ... нужно нормировать 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?
// Здесь multiplier — это 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 и liquidity
Контракт должен иметь баланс для выплаты максимального возможного выигрыша. Минимальный банкролл = MAX_BET × max_multiplier. При 50x multiplier и 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);
}
Frontend: анимация и UX
Визуальная анимация колеса должна быть детерминированной от результата VRF — не рандомной на frontend. Это важно для честности восприятия: результат уже определён 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). Пользователь видит spinning animation + таймер. На L2 (Arbitrum, Base) — быстрее, 1-3 блока. Polygon — ещё быстрее.
Для мгновенного feel: показываем «крутим» анимацию сразу, ждём VRF response, проигрываем финальный spin с раскрытием результата.
NFT бусты и игровые механики
Spin boost NFT. NFT дают дополнительные multiplier на выигрыш (+10%), extra spin раз в 24 часа, доступ к premium колесу с более высокими multiplier-ами. Создаёт вторичный рынок и sink для токенов.
Jackpot механика. Малый процент каждой ставки (1-2%) идёт в jackpot pool. Выпадение специального сектора «JACKPOT» (очень малый вес, 0.1%) забирает весь пул. Это психологически привлекательный механизм.
Daily bonus spin. Бесплатный spin раз в 24 часа с ограниченным max payout. Повышает retention без значимого влияния на house balance.
Стек и инфраструктура
| Компонент | Технология |
|---|---|
| Smart contracts | Solidity + Foundry + OpenZeppelin |
| VRF | Chainlink VRF v2.5 |
| Frontend | React + wagmi + viem |
| Анимация | Framer Motion / GSAP |
| Events мониторинг | viem watchContractEvent |
| NFT | ERC-721 (бусты) + ERC-1155 (cosmetics) |
| Deploy | Arbitrum / Base (низкий gas, быстрые блоки) |
Процесс разработки
Game design (3-5 дней). Дизайн секторов, расчёт RTP и house edge, LP модель, NFT механики. Верификация математики перед кодированием.
Smart contracts (2-3 недели). VRF интеграция, wheel логика, LP механизм, NFT контракты. Foundry тесты с мокнутым VRF coordinator.
Frontend (2-3 недели). Визуализация колеса, анимации, ожидание VRF, wallet интеграция, LP dashboard.
Аудит. VRF интеграция и LP механизм — обязательный аудит. Особое внимание: может ли оператор изменить sectors без timelock, корректность bankroll проверок.
Базовая версия без LP и NFT — 4-5 недель. Полная с LP pool, jackpot, NFT системой — 8-10 недель.







