Розробка PvP-ігор на блокчейні
PvP (Player vs Player) blockchain ігри — один з найскладніших технічних сегментів Web3 gaming. На відміну від казино, де гравець проти house, в PvP потрібно забезпечити чесність між двома гравцями, які не довіряють один одному й обидва хочуть виграти. До того ж реальні гроші на кону — кожен буде шукати експлойти.
Ключові технічні проблеми PvP
Commitment схеми для прихованих дій
У карткових іграх, стратегіях, файтингах гравець не повинен бачити хід суперника до свого ходу. На блокчейні всі дані публічні — як приховати карти?
Commit-reveal паттерн:
contract PvPGame {
struct GameState {
address player1;
address player2;
bytes32 p1CommitHash; // hash(action + secret)
bytes32 p2CommitHash;
uint8 p1Action; // розкривається після commit обох
uint8 p2Action;
Phase phase;
uint256 commitDeadline;
uint256 revealDeadline;
}
enum Phase { WAITING, COMMIT, REVEAL, RESOLVED }
// Фаза 1: обидва гравці надсилають hash(action + secret)
function commitAction(uint256 gameId, bytes32 commitHash) external {
GameState storage game = games[gameId];
require(game.phase == Phase.COMMIT, "Not commit phase");
require(block.timestamp <= game.commitDeadline, "Commit deadline passed");
if (msg.sender == game.player1) {
game.p1CommitHash = commitHash;
} else if (msg.sender == game.player2) {
game.p2CommitHash = commitHash;
} else revert("Not a player");
// Якщо обидва зробили commit — переходимо до reveal
if (game.p1CommitHash != bytes32(0) && game.p2CommitHash != bytes32(0)) {
game.phase = Phase.REVEAL;
game.revealDeadline = block.timestamp + REVEAL_WINDOW;
}
}
// Фаза 2: розкриваємо реальні дії
function revealAction(uint256 gameId, uint8 action, bytes32 secret) external {
GameState storage game = games[gameId];
require(game.phase == Phase.REVEAL, "Not reveal phase");
bytes32 expectedHash = keccak256(abi.encodePacked(action, secret));
if (msg.sender == game.player1) {
require(game.p1CommitHash == expectedHash, "Hash mismatch");
game.p1Action = action;
} else if (msg.sender == game.player2) {
require(game.p2CommitHash == expectedHash, "Hash mismatch");
game.p2Action = action;
}
// Якщо обидва розкрили — вирішуємо
if (game.p1Action != 0 && game.p2Action != 0) {
_resolveGame(gameId);
}
}
// Якщо гравець не розкрив в строк — forfeit
function claimTimeout(uint256 gameId) external {
GameState storage game = games[gameId];
require(game.phase == Phase.REVEAL, "Not reveal phase");
require(block.timestamp > game.revealDeadline, "Deadline not passed");
// Гравець який не розкрив — програє
address winner;
if (game.p1Action == 0 && game.p2Action != 0) {
winner = game.player2;
} else if (game.p2Action == 0 && game.p1Action != 0) {
winner = game.player1;
} else {
// Обидва не розкрили — повернення ставок
_refundBothPlayers(gameId);
return;
}
_payWinner(gameId, winner);
}
}
Приховані карти / приватні дані
Для карткових ігор (Poker, Hearthstone-like) потрібно приховати карти від суперника. Варіанти:
Mental poker (криптографічна роздача карт): класичний алгоритм на основі commutative encryption. Складна в реалізації, але повністю trustless.
Trusted server (гібрид): сервер знає карти, але не може маніпулювати (ключі розділені, commit-reveal). Більшість blockchain карткових ігор використовує цей підхід.
ZK proofs: гравець доводить що його карта в допустимому діапазоні, не розкриваючи її. Технічно складно, latency при proof generation.
Matchmaking й рейтингова система
class EloMatchmaker {
async findMatch(playerId: string): Promise<Match | null> {
const player = await db.getPlayer(playerId);
const searchRange = this.getSearchRange(player.waitTime);
// Ищем опонента в диапазоне рейтинга
const candidates = await db.findAvailablePlayers({
minRating: player.rating - searchRange,
maxRating: player.rating + searchRange,
excludeId: playerId,
});
if (candidates.length === 0) return null;
// Вибираємо найближчий рейтинг
const opponent = candidates.reduce((best, c) =>
Math.abs(c.rating - player.rating) < Math.abs(best.rating - player.rating) ? c : best
);
return this.createMatch(player, opponent);
}
calculateNewRatings(
winner: Player,
loser: Player
): { winnerNew: number; loserNew: number } {
const K = 32; // K-factor
const expectedWin = 1 / (1 + Math.pow(10, (loser.rating - winner.rating) / 400));
return {
winnerNew: Math.round(winner.rating + K * (1 - expectedWin)),
loserNew: Math.round(loser.rating + K * (0 - (1 - expectedWin))),
};
}
}
State Channels для real-time PvP
On-chain кожен хід дорого й повільно. State channels вирішують це: відкриваємо channel (on-chain deposit), граємо off-chain, закриваємо channel (on-chain settlement).
contract PvPStateChannel {
struct Channel {
address player1;
address player2;
uint256 player1Deposit;
uint256 player2Deposit;
uint256 channelNonce; // версія стану
bytes32 stateHash;
bool isOpen;
uint256 disputeTimeout;
}
// Відкриття channel
function openChannel(address opponent) external payable returns (bytes32 channelId) {
channelId = keccak256(abi.encodePacked(msg.sender, opponent, block.timestamp));
channels[channelId] = Channel({
player1: msg.sender,
player2: opponent,
player1Deposit: msg.value,
player2Deposit: 0,
channelNonce: 0,
stateHash: bytes32(0),
isOpen: true,
disputeTimeout: 0,
});
}
// Закриття зі погодженим фінальним станом
function closeChannel(
bytes32 channelId,
uint256 nonce,
uint256 p1Balance,
uint256 p2Balance,
bytes calldata sig1,
bytes calldata sig2
) external {
Channel storage ch = channels[channelId];
bytes32 stateHash = keccak256(abi.encodePacked(channelId, nonce, p1Balance, p2Balance));
// Верифікуємо підписи обох гравців
require(ECDSA.recover(stateHash, sig1) == ch.player1, "Invalid p1 sig");
require(ECDSA.recover(stateHash, sig2) == ch.player2, "Invalid p2 sig");
require(nonce > ch.channelNonce, "Stale state");
require(p1Balance + p2Balance <= ch.player1Deposit + ch.player2Deposit, "Invalid balances");
ch.isOpen = false;
payable(ch.player1).transfer(p1Balance);
payable(ch.player2).transfer(p2Balance);
}
// Якщо суперник не відповідає — dispute resolution
function initiateDispute(
bytes32 channelId,
uint256 nonce,
uint256 p1Balance,
uint256 p2Balance,
bytes calldata mySignature
) external {
Channel storage ch = channels[channelId];
require(ch.disputeTimeout == 0 || block.timestamp < ch.disputeTimeout);
// Публікуємо останнє відомий стан
ch.stateHash = keccak256(abi.encodePacked(channelId, nonce, p1Balance, p2Balance));
ch.channelNonce = nonce;
ch.disputeTimeout = block.timestamp + DISPUTE_WINDOW;
}
}
Анти-чіт й валідація
Для on-chain PvP вся логіка в смарт-контракті — читерство неможливо. Для off-chain + on-chain settlement потрібна серверна валідація:
class GameValidator {
async validateMove(
gameState: GameState,
move: Move,
playerId: string
): Promise<ValidationResult> {
// 1. Перевіряємо очерідність ходу
if (gameState.currentTurn !== playerId) {
return { valid: false, reason: "Not your turn" };
}
// 2. Перевіряємо допустимість ходу по правилам гри
const allowedMoves = this.getAllowedMoves(gameState, playerId);
if (!allowedMoves.includes(move.type)) {
return { valid: false, reason: "Invalid move" };
}
// 3. Перевіряємо що хід не був уже зроблений (replay protection)
if (this.moveCache.has(move.id)) {
return { valid: false, reason: "Duplicate move" };
}
// 4. Перевіряємо timestamp (хід не старше N секунд)
if (Date.now() - move.timestamp > MAX_MOVE_AGE_MS) {
return { valid: false, reason: "Move too old" };
}
return { valid: true };
}
}
Tournament система
contract PvPTournament {
struct Tournament {
uint256 entryFee;
uint256 maxPlayers;
address[] participants;
TournamentType tournamentType; // SINGLE_ELIMINATION, ROUND_ROBIN, SWISS
uint256 prizePool;
TournamentStatus status;
}
// Prize distribution (в basis points)
uint256[] public prizeDistribution = [5000, 3000, 1500, 500]; // 50%, 30%, 15%, 5%
function registerForTournament(uint256 tournamentId) external payable {
Tournament storage t = tournaments[tournamentId];
require(t.participants.length < t.maxPlayers, "Tournament full");
require(msg.value == t.entryFee, "Wrong entry fee");
t.participants.push(msg.sender);
t.prizePool += msg.value;
}
function distributePrizes(uint256 tournamentId, address[] calldata rankedPlayers)
external onlyAdmin
{
Tournament storage t = tournaments[tournamentId];
for (uint i = 0; i < t.prizes.length && i < rankedPlayers.length; i++) {
uint256 prize = (t.prizePool * t.prizes[i].percent) / 10000;
payable(rankedPlayers[i]).transfer(prize);
}
}
}
Стек
| Компонент | Технологія |
|---|---|
| Смарт контракти | Solidity + Foundry, State Channels |
| Randomness | Chainlink VRF v2.5 |
| Real-time комунікація | WebSocket (Socket.io) |
| Game server | Node.js + TypeScript |
| Matchmaking | Redis sorted sets |
| Фронтенд | React / Unity WebGL |
| L2 | Arbitrum / Starknet (для ZK ігор) |
| Anti-cheat | Server-side валідація + zkProofs |
Часові рамки
- 1v1 гра (Coinflip, RPS, базова картка): 4-6 тижнів
- State Channel PvP: +3-4 тижні
- Tournament система: +3-4 тижні
- Складна картка/стратегія гра: 3-5 місяців
- Security audit: обов'язковий, +4-6 тижнів







