Разработка игры Tower на блокчейне
Tower (башня) — азартная игра с нарастающим риском: игрок поднимается по уровням, на каждом уровне выбирает одну из нескольких ячеек, одна из которых — «мина». Чем выше уровень — тем больше множитель выигрыша. В любой момент можно «кешаут» и забрать накопленный выигрыш. Структурно похожа на Mines, но с прогрессивным ростом ставок.
Блокчейн Tower интересна тем, что требует честного рандома на каждом уровне отдельно, при этом игрок не должен знать заранее расположение мины на следующем уровне.
Архитектура честного рандома
Ключевая проблема: где располагается мина на каждом уровне? On-chain данные публичны — если хранить расположение мины в storage, игрок может прочитать до хода.
Подход 1: Commit-Reveal per level
Оператор/оракул генерирует хэш seed для каждого уровня до начала игры, публикует hash on-chain, раскрывает seed только когда игрок сделал выбор на этом уровне:
struct TowerGame {
address player;
uint256 bet;
uint8 currentLevel; // текущий уровень (0 = начало)
uint8 maxLevels; // высота башни
uint256 currentMultiplier; // x1000 для точности
bytes32 serverSeedHash; // хэш seed от сервера
bool active;
}
Проблема: требует backend-а, который честно играет (не может подставить мину post-hoc). Решение — публикация hash до начала игры. Если сервер раскрывает seed несовпадающий с hash — нарушение верифицируемо.
Подход 2: Chainlink VRF per game
Запросить один большой random в начале игры, детерминированно выводить расположение мины на каждом уровне:
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 transactions.
Практическое решение для 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 добавляется через коэффициент:
// Multiplier таблица (x1000, 3 ячейки, house edge 2%)
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);
}
Frontend: анимации и 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 disclosure множителя: показывать анимацию роста множителя при каждом успешном уровне — это ключевой момент удержания. Текущий возможный выигрыш должен быть виден крупно, в реалтайм.
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 |
| Frontend | React + wagmi |
| Сеть | Arbitrum / Polygon |
Базовая Tower игра (смарт-контракт, тесты, UI): 3-4 недели. С расширенными визуальными эффектами, статистикой, leaderboard: 6-8 недель. Аудит смарт-контракта обязателен — контракт управляет игровым банкроллом.







