Разработка игры Hilo на блокчейне
HiLo — карточная игра с простой механикой: угадать, будет ли следующая карта выше или ниже текущей. При правильном угадывании умножитель растёт, в любой момент можно "кешаут" и забрать выигрыш. Простота механики делает HiLo идеальным примером для provably fair реализации на блокчейне — вся логика прозрачна и верифицируема.
Контракт: commit-reveal схема
Chainlink VRF для HiLo слишком медленный — игра предполагает быстрые решения. Используем server seed + client seed commit-reveal: казино публикует hash сида заранее, игрок добавляет свой seed, результат детерминирован из комбинации обоих. Ни казино не может знать seed игрока заранее, ни игрок не может знать server seed.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract HiLoGame {
uint8 constant DECK_SIZE = 52;
struct Game {
address player;
bytes32 serverSeedHash; // hash сида, опубликованный до игры
bytes32 clientSeed; // сид игрока, revealed в конце
string serverSeed; // revealed после игры
uint256 betAmount;
uint8 currentCard; // текущая карта (0-51)
uint8 position; // позиция в колоде
uint256 multiplier; // x100 для точности (200 = 2.0x)
bool active;
bool cashed;
}
mapping(bytes32 => Game) public games; // gameId → Game
uint256 public constant HOUSE_EDGE = 100; // 1% (100/10000)
event GameStarted(bytes32 indexed gameId, address player, uint8 firstCard);
event CardRevealed(bytes32 indexed gameId, uint8 card, uint256 multiplier);
event GameCashed(bytes32 indexed gameId, uint256 payout);
event GameLost(bytes32 indexed gameId, uint8 card);
// Казино публикует hash server seed до начала игры
function startGame(
bytes32 serverSeedHash,
bytes32 clientSeed
) external payable returns (bytes32 gameId) {
require(msg.value > 0, "Bet required");
gameId = keccak256(abi.encodePacked(
msg.sender, serverSeedHash, clientSeed, block.timestamp
));
// Генерируем первую карту из clientSeed + serverSeedHash
// (server seed ещё не known, но hash зафиксирован)
uint8 firstCard = _deriveCard(serverSeedHash, clientSeed, 0);
games[gameId] = Game({
player: msg.sender,
serverSeedHash: serverSeedHash,
clientSeed: clientSeed,
serverSeed: "",
betAmount: msg.value,
currentCard: firstCard,
position: 0,
multiplier: 100, // 1.0x
active: true,
cashed: false
});
emit GameStarted(gameId, msg.sender, firstCard);
}
// Игрок делает выбор: Higher (true) или Lower (false)
// Казино вызывает reveal следующей карты вместе с частичным server seed
function revealNextCard(
bytes32 gameId,
string calldata serverSeedPartial, // частичный reveal для верификации
bool guessHigher
) external {
Game storage game = games[gameId];
require(game.active, "Game not active");
require(msg.sender == owner() || msg.sender == gameServer, "Unauthorized");
uint8 nextCard = _deriveCard(
game.serverSeedHash,
game.clientSeed,
game.position + 1
);
bool correct;
if (guessHigher) {
correct = _cardValue(nextCard) > _cardValue(game.currentCard);
} else {
correct = _cardValue(nextCard) < _cardValue(game.currentCard);
}
// Ничья (равные карты) — проигрыш
if (_cardValue(nextCard) == _cardValue(game.currentCard)) {
correct = false;
}
game.position++;
game.currentCard = nextCard;
if (!correct) {
game.active = false;
emit GameLost(gameId, nextCard);
return;
}
// Обновляем multiplier: вероятность угадать * house edge
uint256 probability = _calculateProbability(game.currentCard, guessHigher);
game.multiplier = (game.multiplier * 9900) / probability; // 9900 = 99% (1% house edge)
emit CardRevealed(gameId, nextCard, game.multiplier);
}
// Игрок забирает выигрыш
function cashout(bytes32 gameId) external {
Game storage game = games[gameId];
require(game.active, "Game not active");
require(msg.sender == game.player, "Not your game");
game.active = false;
game.cashed = true;
uint256 payout = (game.betAmount * game.multiplier) / 100;
payable(game.player).transfer(payout);
emit GameCashed(gameId, payout);
}
// После окончания игры казино reveals полный server seed
// Игрок может верифицировать: hash(serverSeed) == serverSeedHash
function revealServerSeed(bytes32 gameId, string calldata serverSeed) external {
Game storage game = games[gameId];
require(!game.active, "Game still active");
require(
keccak256(bytes(serverSeed)) == game.serverSeedHash,
"Invalid server seed"
);
game.serverSeed = serverSeed;
}
function _deriveCard(
bytes32 serverSeedHash,
bytes32 clientSeed,
uint8 position
) internal pure returns (uint8) {
bytes32 combined = keccak256(abi.encodePacked(serverSeedHash, clientSeed, position));
return uint8(uint256(combined) % DECK_SIZE);
}
function _cardValue(uint8 card) internal pure returns (uint8) {
return (card % 13) + 1; // 1=Ace, 13=King
}
function _calculateProbability(uint8 currentCard, bool higher) internal pure returns (uint256) {
uint8 value = _cardValue(currentCard);
uint256 cardsHigher = 13 - value;
uint256 cardsLower = value - 1;
// Возвращаем probability * 100 (для точности)
if (higher) return (cardsHigher * 100 * 100) / 13; // *100 для масштаба multiplier
return (cardsLower * 100 * 100) / 13;
}
receive() external payable {}
address public gameServer;
address public owner;
constructor() { owner = msg.sender; gameServer = msg.sender; }
modifier onlyOwner() { require(msg.sender == owner); _; }
}
Верифицируемость для игрока
Провабл-фэйр работает так: после игры пользователь берёт revealed serverSeed и вычисляет keccak256(serverSeed) — если совпадает с serverSeedHash, опубликованным до игры, казино не подменяло сид. Затем воспроизводит последовательность карт через _deriveCard — результаты должны совпасть с игрой.
// Клиентская верификация (JavaScript)
import { keccak256, encodePacked } from "viem";
function verifyGame(
serverSeed: string,
serverSeedHash: string,
clientSeed: string,
cards: number[]
): boolean {
// Проверяем, что hash совпадает
const computedHash = keccak256(new TextEncoder().encode(serverSeed));
if (computedHash !== serverSeedHash) return false;
// Воспроизводим карты
for (let i = 0; i < cards.length; i++) {
const combined = keccak256(
encodePacked(["bytes32", "bytes32", "uint8"], [serverSeedHash, clientSeed as `0x${string}`, i])
);
const card = Number(BigInt(combined) % 52n);
if (card !== cards[i]) return false;
}
return true;
}
Фронтенд
Анимация карт — CSS flip transitions или Pixi.js. Ключевые элементы UI: текущая карта, история последних 5 карт, текущий multiplier, кнопки Higher/Lower, кнопка Cash Out. Multiplier должен обновляться анимированно при каждом правильном угадывании — это core feedback loop игры.
Для быстрого feedback без ожидания on-chain confirmation — используем optimistic UI: показываем результат немедленно на основе данных от game server, подтверждаем on-chain асинхронно. Если on-chain транзакция фейлится — откатываем состояние.







