Розробка гри Tower на блокчейні
Tower (башта) — азартна гра з зростаючим ризиком: гравець піднімається по рівням, на кожному рівні вибирає одну з кількох клітинок, одна з яких — «міна». Чим вищий рівень — тим більший множник виграшу. В будь-який момент можна «кешаутити» і забрати накопичений виграш. Структурно схожа на Mines, але з прогресивним зростанням ставок.
Blockchain Tower цікава тим, що вимагає чесного рандома на кожному рівні окремо, при цьому гравець не повинен знати заздалегідь розташування міни на наступному рівні.
Архітектура чесного рандома
Ключова проблема: де розташована міна на кожному рівні? On-chain дані публічні — якщо зберігати розташування міни в storage, гравець може прочитати до ходу.
Підхід 1: Commit-Reveal за рівень
Оператор/оракул генерує хеш seed для кожного рівня до початку гри, публікує hash on-chain, розкриває seed тільки коли гравець зробив вибір на цьому рівні:
struct TowerGame {
address player;
uint256 bet;
uint8 currentLevel; // поточний рівень (0 = початок)
uint8 maxLevels; // висота башти
uint256 currentMultiplier; // x1000 для точності
bytes32 serverSeedHash; // хеш seed від сервера
bool active;
}
Проблема: вимагає бекенду, який чесно грає (не може підставити міну post-hoc). Рішення — публікація hash до початку гри. Якщо сервер розкриває seed, що не збігається з hash — порушення верифіковане.
Підхід 2: Chainlink VRF на гру
Запросити одне велике випадкове число на початку гри, детерміновано виводити розташування міни на кожному рівні:
mapping(uint256 => TowerGame) public games; // requestId → game
function startTower(uint8 levels, uint8 cellsPerLevel) external payable {
require(msg.value >= MIN_BET);
require(levels >= 3 && levels <= 10);
require(cellsPerLevel >= 2 && cellsPerLevel <= 5);
uint256 requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: KEY_HASH,
subId: subscriptionId,
requestConfirmations: 3,
callbackGasLimit: 200000,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
games[requestId] = TowerGame({
player: msg.sender,
bet: msg.value,
currentLevel: 0,
maxLevels: levels,
currentMultiplier: 1000, // x1.0
gameSeed: 0, // заповниться в fulfillment
active: false
});
}
function fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) internal override {
TowerGame storage game = games[requestId];
game.gameSeed = randomWords[0];
game.active = true;
emit TowerReady(requestId, game.player);
}
// Отримати позицію міни для рівня (тільки коли хід зроблений!)
function _getMinePosition(uint256 requestId, uint8 level, uint8 cellsPerLevel) private view returns (uint8) {
return uint8(uint256(keccak256(abi.encodePacked(
games[requestId].gameSeed,
level
))) % cellsPerLevel);
}
_getMinePosition — private view. Технічно читаємо якщо знаєш gameSeed. Але gameSeed зберігається в storage... і знову публічно.
Рішення: приховування seed через хеш
Зберігати тільки keccak256(gameSeed) в events, сам seed — тільки як параметр у транзакції, не в storage. Це не ідеально, але підвищує планку для шахрайства: потрібно моніторити pending транзакції.
Практичне рішення для production: гібрид — Chainlink VRF для нечитаємого seed + збереження тільки хеша seed публічно. Позиція міни розкривається через подію тільки після ходу гравця і не зберігається в storage до ходу.
Множники й математика
Кожен рівень башти з n клітинками і однією миною: ймовірність безпечного вибору = (n-1)/n. Математично чесний множник після k рівнів:
multiplier(k) = product_{i=1}^{k} (n_i / (n_i - 1))
Для башти 5 рівнів, 3 клітинки: кожен рівень ×(3/2) = ×1.5. Після 5 рівнів: 1.5^5 ≈ 7.59x. House edge додається через коефіцієнт:
// Таблиця множників (x1000, 3 клітинки, 2% house edge)
uint256[10] public multipliers3Cells = [
0, // рівень 0
1470, // x1.47 (чесне 1.5 * 0.98)
2161, // x2.16
3177, // x3.18
4670, // x4.67
6865, // x6.87
10092, // x10.09
14835, // x14.84
21807, // x21.81
32056 // x32.06
];
function selectCell(uint256 gameId, uint8 cellIndex) external {
TowerGame storage game = games[gameId];
require(game.player == msg.sender && game.active);
require(cellIndex < cellsPerLevel);
uint8 minePosition = _getMinePosition(gameId, game.currentLevel, cellsPerLevel);
if (cellIndex == minePosition) {
// Попав в міну — втрата ставки
game.active = false;
emit GameLost(gameId, msg.sender, game.currentLevel, minePosition);
// ETH залишається в контракті (bankroll)
} else {
// Пройшов рівень — оновити множник
game.currentMultiplier = multipliers[game.currentLevel + 1];
game.currentLevel++;
if (game.currentLevel == game.maxLevels) {
// Пройшов всю башту — автоматичний кешаут
_payout(game);
} else {
emit LevelCleared(gameId, game.currentLevel, game.currentMultiplier);
}
}
}
function cashout(uint256 gameId) external {
TowerGame storage game = games[gameId];
require(game.player == msg.sender && game.active && game.currentLevel > 0);
_payout(game);
}
function _payout(TowerGame storage game) private {
uint256 payout = game.bet * game.currentMultiplier / 1000;
game.active = false;
(bool success, ) = game.player.call{value: payout}("");
require(success, "Transfer failed");
emit GameWon(msg.sender, payout, game.currentLevel);
}
Фронтенд: анімації й UX
Tower гра візуально проста, але UX критична: анімація піднімання, пульсація множника, кнопка кешаут повинна бути завжди доступна.
Асинхронний флоу: VRF fulfillment чекаємо через polling подій TowerReady. Після — кожен хід — миттєва on-chain транзакція (без додаткового VRF).
// wagmi hook для очікування старту гри
const { data: gameReadyEvent } = useWatchContractEvent({
address: TOWER_ADDRESS,
abi: TOWER_ABI,
eventName: 'TowerReady',
args: { player: address },
onLogs: (logs) => {
const gameId = logs[0].args.requestId
setActiveGameId(gameId)
setGameState('playing')
}
})
Progressive множника розкриття: показувати анімацію зростання множника при кожному успішному рівні — це ключовий момент утримання. Поточний можливий виграш повинен бути видно крупно, в реалтайм.
Bankroll й ліміти
Максимальний виграш обмежений bankroll-ом. Перевірка до прийняття ставки:
function maxWinForBet(uint256 bet) public view returns (uint256) {
return bet * multipliers[maxLevels] / 1000;
}
modifier bankrollSufficient(uint256 bet) {
require(address(this).balance >= maxWinForBet(bet) + bet, "Insufficient bankroll");
_;
}
Стек й часові рамки
| Компонент | Технологія |
|---|---|
| Контракт | Solidity + Chainlink VRF |
| Тести | Foundry + VRF mock |
| Фронтенд | React + wagmi |
| Мережа | Arbitrum / Polygon |
Базова Tower гра (смарт-контракт, тести, UI): 3-4 тижні. З розширеними візуальними ефектами, статистикою, leaderboard: 6-8 тижнів. Аудит смарт-контракту обов'язковий — контракт управляє ігровим bankroll-ом.







