Blockchain HiLo Game Development
HiLo is a simple card game: guess if next card is higher or lower than current. Correct guess grows multiplier, at any time you can "cashout" and take winnings. Simple mechanics makes HiLo ideal example for provably fair blockchain implementation — all logic transparent and verifiable.
Contract: Commit-Reveal Scheme
Chainlink VRF for HiLo too slow — game expects quick decisions. Use server seed + client seed commit-reveal: casino publishes hash of seed beforehand, player adds own seed, result deterministic from combination. Neither casino knows player seed beforehand nor player knows 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 of seed, published before game
bytes32 clientSeed; // player's seed, revealed at end
string serverSeed; // revealed after game
uint256 betAmount;
uint8 currentCard; // current card (0-51)
uint8 position; // position in deck
uint256 multiplier; // x100 for precision (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);
// Casino publishes hash of server seed before game
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
));
// Generate first card from clientSeed + serverSeedHash
// (server seed unknown yet, but hash is fixed)
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);
}
// Player makes choice: Higher (true) or Lower (false)
// Casino calls reveal next card with partial server seed
function revealNextCard(
bytes32 gameId,
string calldata serverSeedPartial, // partial reveal for verification
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);
}
// Tie (equal cards) — loss
if (_cardValue(nextCard) == _cardValue(game.currentCard)) {
correct = false;
}
game.position++;
game.currentCard = nextCard;
if (!correct) {
game.active = false;
emit GameLost(gameId, nextCard);
return;
}
// Update multiplier: guess probability * 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);
}
// Player takes winnings
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);
}
// After game ends, casino reveals full server seed
// Player can verify: 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;
// Return probability * 100 (for precision)
if (higher) return (cardsHigher * 100 * 100) / 13; // *100 for multiplier scale
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); _; }
}
Verifiability for Player
Provably fair works like this: after game user takes revealed serverSeed and computes keccak256(serverSeed) — if matches serverSeedHash published before game, casino didn't substitute seed. Then reproduces card sequence via _deriveCard — results should match game.
Client-side verification possible via JavaScript.
Frontend
Card animations — CSS flip transitions or Pixi.js. Key UI elements: current card, last 5 cards history, current multiplier, Higher/Lower buttons, Cash Out button. Multiplier should update animatedly on each correct guess — core feedback loop of game.
For fast feedback without waiting for on-chain confirmation — use optimistic UI: show result immediately based on game server data, confirm on-chain asynchronously. If on-chain transaction fails — rollback state.







