Blockchain PvP Games Development
PvP (Player vs Player) blockchain games — one of the most technically complex Web3 gaming segments. Unlike casinos where a player faces the house, PvP requires ensuring fairness between two players who don't trust each other and both want to win. Add real money at stake — everyone will search for exploits.
Key Technical Challenges in PvP
Commitment Schemes for Hidden Actions
In card games, strategies, fighting games — a player shouldn't see opponent's move before their own. On blockchain all data is public — how to hide cards?
Commit-reveal pattern:
contract PvPGame {
struct GameState {
address player1;
address player2;
bytes32 p1CommitHash; // hash(action + secret)
bytes32 p2CommitHash;
uint8 p1Action; // revealed after both commit
uint8 p2Action;
Phase phase;
uint256 commitDeadline;
uint256 revealDeadline;
}
enum Phase { WAITING, COMMIT, REVEAL, RESOLVED }
// Phase 1: both players submit 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");
// If both committed — move to reveal
if (game.p1CommitHash != bytes32(0) && game.p2CommitHash != bytes32(0)) {
game.phase = Phase.REVEAL;
game.revealDeadline = block.timestamp + REVEAL_WINDOW;
}
}
// Phase 2: reveal real actions
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 both revealed — resolve
if (game.p1Action != 0 && game.p2Action != 0) {
_resolveGame(gameId);
}
}
// If player doesn't reveal in time — 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");
// Player who didn't reveal — loses
address winner;
if (game.p1Action == 0 && game.p2Action != 0) {
winner = game.player2;
} else if (game.p2Action == 0 && game.p1Action != 0) {
winner = game.player1;
} else {
// Both didn't reveal — refund stakes
_refundBothPlayers(gameId);
return;
}
_payWinner(gameId, winner);
}
}
Hidden Cards / Private Data
For card games (Poker, Hearthstone-like) — need to hide cards from opponent. Options:
Mental poker (cryptographic card dealing): classic algorithm based on commutative encryption. Complex to implement, but fully trustless.
Trusted server (hybrid): server knows cards but cannot manipulate (keys shared, commit-reveal). Most blockchain card games use this approach.
ZK proofs: player proves their card within allowed range without revealing. Technically complex, latency during proof generation.
Matchmaking and Rating System
class EloMatchmaker {
async findMatch(playerId: string): Promise<Match | null> {
const player = await db.getPlayer(playerId);
const searchRange = this.getSearchRange(player.waitTime);
// Find opponent within rating range
const candidates = await db.findAvailablePlayers({
minRating: player.rating - searchRange,
maxRating: player.rating + searchRange,
excludeId: playerId,
});
if (candidates.length === 0) return null;
// Select closest rating
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 for Real-time PvP
On-chain each move is expensive and slow. State channels solve this: open channel (on-chain deposit), play off-chain, close channel (on-chain settlement).
contract PvPStateChannel {
struct Channel {
address player1;
address player2;
uint256 player1Deposit;
uint256 player2Deposit;
uint256 channelNonce; // state version
bytes32 stateHash;
bool isOpen;
uint256 disputeTimeout;
}
// Open 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,
});
}
// Close with agreed final state
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));
// Verify both players' signatures
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);
}
// If opponent doesn't respond — 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);
// Publish last known state
ch.stateHash = keccak256(abi.encodePacked(channelId, nonce, p1Balance, p2Balance));
ch.channelNonce = nonce;
ch.disputeTimeout = block.timestamp + DISPUTE_WINDOW;
}
}
Anti-cheat and Validation
For on-chain PvP all logic is in smart contract — cheating impossible. For off-chain + on-chain settlement — server-side validation needed:
class GameValidator {
async validateMove(
gameState: GameState,
move: Move,
playerId: string
): Promise<ValidationResult> {
// 1. Check move turn order
if (gameState.currentTurn !== playerId) {
return { valid: false, reason: "Not your turn" };
}
// 2. Check move legality per game rules
const allowedMoves = this.getAllowedMoves(gameState, playerId);
if (!allowedMoves.includes(move.type)) {
return { valid: false, reason: "Invalid move" };
}
// 3. Check move wasn't already made (replay protection)
if (this.moveCache.has(move.id)) {
return { valid: false, reason: "Duplicate move" };
}
// 4. Check timestamp (move not older than N seconds)
if (Date.now() - move.timestamp > MAX_MOVE_AGE_MS) {
return { valid: false, reason: "Move too old" };
}
return { valid: true };
}
}
Tournament System
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);
}
}
}
Stack
| Component | Technology |
|---|---|
| Smart contracts | Solidity + Foundry, State Channels |
| Randomness | Chainlink VRF v2.5 |
| Real-time comms | WebSocket (Socket.io) |
| Game server | Node.js + TypeScript |
| Matchmaking | Redis sorted sets |
| Frontend | React / Unity WebGL |
| L2 | Arbitrum / Starknet (for ZK games) |
| Anti-cheat | Server-side validation + zkProofs |
Timeline
- 1v1 game (Coinflip, RPS, basic card): 4-6 weeks
- State Channel PvP: +3-4 weeks
- Tournament system: +3-4 weeks
- Complex card/strategy game: 3-5 months
- Security audit: mandatory, +4-6 weeks







