This repository implements a modular smart contract system in Solidity to manage ETH-based PvP Rock–Paper–Scissors duels using the commit-reveal pattern for secure and fair decision-making:
DuelManager.sol: handles duel creation, player payments, and ETH transfers.DuelResolver.sol: commit-reveal logic for determining duel outcomes.interfaces/IDuelResolver.sol: interface used byDuelManagerto remain agnostic to resolution logic.mocks/: test-friendly resolver implementations for simulating edge cases.
The architecture ensures:
- Game fairness via cryptographic commitments
- Proper separation of funds and logic
- Extensibility for alternative resolvers (DAO, randomness, etc.)
├── interfaces/
│ └── IDuelResolver.sol # Interface expected by the DuelManager
├── mocks/
│ ├── MockResolverAlwaysDraw.sol # Always returns a draw
│ └── MockResolverAlwaysWin.sol # Always returns a fixed winner
├── DuelManager.sol # Main controller of duels and payouts
├── DuelResolver.sol # Commit-reveal implementation
Defines the expected interface for any resolver:
interface IDuelResolver {
function commitMove(uint256 duelId, bytes32 hash) external;
function revealMove(uint256 duelId, string calldata move, string calldata salt) external;
function resolveDuel(uint256 duelId) external view returns (address winner);
function getPlayers(uint256 duelId) external view returns (address[2] memory);
function hasRevealed(uint256 duelId, address player) external view returns (bool);
function firstRevealTimestamp(uint256 duelId) external view returns (uint256);
function registerPlayers(uint256 duelId, address p1, address p2) external;
}Handles:
- Duel lifecycle and player payments
- Linking players with the resolver
- Calls
resolveDuel()orclaimVictoryIfTimeout()based on game state
function createDuel(address opponent) external payable returns (uint256 duelId);
function acceptDuel(uint256 duelId) external payable;
function declareWinner(uint256 duelId) external;
function claimVictoryIfTimeout(uint256 duelId) external;Implements commit-reveal resolution logic:
function commitMove(uint256 duelId, bytes32 hash) external;
function revealMove(uint256 duelId, string calldata move, string calldata salt) external;
function resolveDuel(uint256 duelId) external view returns (address winner);
function registerPlayers(uint256 duelId, address p1, address p2) external;Includes firstRevealTimestamp to support timeout-based resolution.
- A constant
REVEAL_TIMEOUT = 10 minutesis enforced after first reveal. - If only one player reveals → that player can claim victory.
- If neither reveals → duel results in a draw and refunds both players.
DuelManager manager = new DuelManager(msg.sender, address(0));DuelResolver resolver = new DuelResolver(address(manager));manager.setResolver(address(resolver));manager.createDuel(opponent); // Player1 creates
manager.acceptDuel(duelId); // Player2 acceptsTo commit your move securely, generate a keccak256 hash using:
keccak256(abi.encodePacked("rock", "mySecretSalt"));In JavaScript / Remix console:
const { keccak256, solidityPack } = ethers.utils;
const move = "rock"; // "rock", "paper", or "scissors"
const salt = "mySecretSalt"; // A unique string per game
const packed = solidityPack(["string", "string"], [move, salt]);
const hash = keccak256(packed);✅ Make sure you use exact lowercase for moves.
❌ Do not include quotes or extra spaces inside the string.
Then call:
resolver.commitMove(duelId, hash);Later, when revealing:
resolver.revealMove(duelId, "rock", "mySecretSalt");resolver.commitMove(duelId, keccak256(...));
resolver.revealMove(duelId, "rock", "mysalt");- If both players reveal → arbiter calls:
manager.declareWinner(duelId);- If only one reveals after timeout → anyone calls:
manager.claimVictoryIfTimeout(duelId);Licensed under the GNU General Public License v3.0 – see the LICENSE file.
Open an issue or PR for improvements, questions or feedback. Contributions welcome!