// SPDX-License-Identifier: MIT pragma solidity >=0.7.3; contract Game { uint public constant BET_MIN = 1e16; // The minimum bet (1 BLD) uint public constant REVEAL_TIMEOUT = 10 minutes; // Max delay of revelation phase uint public constant COMMIT_TIMEOUT = 10 minutes; // Max delay of commit phase enum Moves { None, Rock, Paper, Scissors } enum Outcomes { None, PlayerA, PlayerB, Draw, PlayerATimeout, PlayerBTimeout } // Possible outcomes struct Player { address payable addr; uint bet; bytes32 encrMove; Moves move; string nickname; } struct GameState { Player playerA; Player playerB; Outcomes outcome; uint firstCommit; uint firstReveal; uint initialBet; uint gameId; bool isActive; } // Mapping from game ID to game state mapping(uint => GameState) private games; // Array to track all game IDs (for enumeration) uint[] private gameIds; // Counter for generating unique game IDs uint private nextGameId = 1; // ------------------------- Registration ------------------------- // modifier validBet(uint gameId) { require(msg.value == BET_MIN, "Minimum bet not met"); require( games[gameId].initialBet == 0 || msg.value == games[gameId].initialBet, "Bet value must match initial bet" ); _; } // Register a player to an existing game or create a new game. // If gameId is 0, player will join or create the first available game. // Return player's ID and game ID upon successful registration. function register( uint gameId ) public payable validBet(gameId) returns (uint playerId, uint returnGameId) { // If gameId is 0, find an open game or create a new one if (gameId == 0) { gameId = createNewGame(); } require(games[gameId].isActive, "Game is not active"); GameState storage game = games[gameId]; if (game.playerA.addr == address(0x0)) { game.playerA.addr = payable(msg.sender); game.initialBet = msg.value; return (1, gameId); } else if (game.playerB.addr == address(0x0)) { require( msg.sender != game.playerA.addr, "Cannot play against yourself" ); game.playerB.addr = payable(msg.sender); return (2, gameId); } revert("Game is full"); } // Create a new game function createNewGame() private returns (uint) { uint gameId = nextGameId; nextGameId++; games[gameId].gameId = gameId; games[gameId].isActive = true; gameIds.push(gameId); return gameId; } // ------------------------- Commit ------------------------- // modifier isRegistered(uint gameId) { require(gameId != 0, "Invalid game ID"); require( msg.sender == games[gameId].playerA.addr || msg.sender == games[gameId].playerB.addr, "Player not registered in this game" ); _; } // Save player's encrypted move. encrMove must be "<1|2|3>-password" hashed with sha256. // Return 'true' if move was valid, 'false' otherwise. function play(uint gameId, bytes32 encrMove) public isRegistered(gameId) returns (bool) { GameState storage game = games[gameId]; // Check if game is still active (commit phase not timed out) require(game.isActive, "Game is no longer active"); require(game.firstCommit == 0 || block.timestamp <= game.firstCommit + COMMIT_TIMEOUT, "Commit phase timeout expired"); // Basic sanity checks with explicit errors to help debugging require(encrMove != bytes32(0), "Encrypted move cannot be zero"); // Track first commit timestamp if (game.firstCommit == 0) { game.firstCommit = block.timestamp; } // Ensure the caller hasn't already committed a move if (msg.sender == game.playerA.addr) { require( game.playerA.encrMove == bytes32(0), "Player A already committed" ); game.playerA.encrMove = encrMove; } else if (msg.sender == game.playerB.addr) { require( game.playerB.encrMove == bytes32(0), "Player B already committed" ); game.playerB.encrMove = encrMove; } else { revert("Caller not registered"); } return true; } // ------------------------- Reveal ------------------------- // modifier commitPhaseEnded(uint gameId) { require(gameId != 0, "Invalid game ID"); require( games[gameId].playerA.encrMove != bytes32(0) && games[gameId].playerB.encrMove != bytes32(0), "Commit phase not ended" ); _; } // Compare clear move given by the player with saved encrypted move. // Return clear move upon success, 'Moves.None' otherwise. function reveal( uint gameId, string memory clearMove ) public isRegistered(gameId) commitPhaseEnded(gameId) returns (Moves) { GameState storage game = games[gameId]; // Check if reveal phase timeout has expired if( game.firstReveal != 0 ) { require(block.timestamp <= game.firstReveal + REVEAL_TIMEOUT, "Reveal phase timeout expired"); } bytes32 encrMove = keccak256(abi.encodePacked(clearMove)); // Hash of clear input (= "move-password") Moves move = Moves(getFirstChar(clearMove)); // Actual move (Rock / Paper / Scissors) // If move invalid, exit require(move != Moves.None, "Invalid move"); // If hashes match, clear move is saved if ( msg.sender == game.playerA.addr && encrMove == game.playerA.encrMove ) { game.playerA.move = move; } else if ( msg.sender == game.playerB.addr && encrMove == game.playerB.encrMove ) { game.playerB.move = move; } else { return Moves.None; } // Timer starts after first revelation from one of the player if (game.firstReveal == 0) { game.firstReveal = block.timestamp; } if( game.playerA.move != Moves.None && game.playerB.move != Moves.None ) { // Both players have revealed, compute outcome if (game.playerA.move == game.playerB.move) { game.outcome = Outcomes.Draw; } else if ( (game.playerA.move == Moves.Rock && game.playerB.move == Moves.Scissors) || (game.playerA.move == Moves.Paper && game.playerB.move == Moves.Rock) || (game.playerA.move == Moves.Scissors && game.playerB.move == Moves.Paper) ) { game.outcome = Outcomes.PlayerA; } else { game.outcome = Outcomes.PlayerB; } } return move; } // Return first character of a given string. // Returns 0 if the string is empty or the first character is not '1','2' or '3'. function getFirstChar(string memory str) private pure returns (uint) { bytes memory b = bytes(str); if (b.length == 0) { return 0; } bytes1 firstByte = b[0]; if (firstByte == 0x31) { return 1; } else if (firstByte == 0x32) { return 2; } else if (firstByte == 0x33) { return 3; } else { return 0; } } // ------------------------- Result ------------------------- // modifier revealPhaseEnded(uint gameId) { require(gameId != 0, "Invalid game ID"); require( (games[gameId].playerA.move != Moves.None && games[gameId].playerB.move != Moves.None) || (games[gameId].firstReveal != 0 && block.timestamp > games[gameId].firstReveal + REVEAL_TIMEOUT), "Reveal phase not ended" ); _; } // Compute the outcome and pay the winner(s). // Return the outcome. function getOutcome(uint gameId) public revealPhaseEnded(gameId) returns (Outcomes) { GameState storage game = games[gameId]; require( game.outcome != Outcomes.None, "Outcome not yet determined" ); address payable addrA = game.playerA.addr; address payable addrB = game.playerB.addr; uint betPlayerA = game.initialBet; // Reset and cleanup resetGame(gameId); // Reset game before paying to avoid reentrancy attacks pay(addrA, addrB, betPlayerA, game.outcome); return game.outcome; } // Pay the winner(s). function pay( address payable addrA, address payable addrB, uint betPlayerA, Outcomes outcome ) private { if (outcome == Outcomes.PlayerA) { addrA.transfer(betPlayerA * 2); } else if (outcome == Outcomes.PlayerB) { addrB.transfer(betPlayerA * 2); } else { addrA.transfer(betPlayerA); addrB.transfer(betPlayerA); } } // Pay to one player and slash the other (timeout resolution). function payWithSlash( address payable winner, address payable loser, uint betAmount ) private { // Winner gets both bets winner.transfer(betAmount * 2); // Loser gets nothing (slashed) } // Reset a specific game. function resetGame(uint gameId) private { GameState storage game = games[gameId]; // Mark game as inactive game.isActive = false; // Note: We keep the game data in the mapping for reference // but players are now free to join other games } // ------------------------- Helpers ------------------------- // // Return contract balance function getContractBalance() public view returns (uint) { return address(this).balance; } // Return player's ID in their active game function whoAmI(uint gameId) public view returns (uint) { if (gameId == 0) { return 0; } GameState storage game = games[gameId]; if (msg.sender == game.playerA.addr) { return 1; } else if (msg.sender == game.playerB.addr) { return 2; } else { return 0; } } // Return time left before the end of the revelation phase. function revealTimeLeft(uint gameId) public view returns (int) { if (gameId == 0) return int(REVEAL_TIMEOUT); GameState storage game = games[gameId]; if (game.firstReveal != 0) { uint deadline = game.firstReveal + REVEAL_TIMEOUT; if (block.timestamp >= deadline) { return 0; } return int(deadline - block.timestamp); } return int(REVEAL_TIMEOUT); } // Return time left before the end of the commit phase. function commitTimeLeft(uint gameId) public view returns (int) { if (gameId == 0) return int(COMMIT_TIMEOUT); GameState storage game = games[gameId]; if (game.firstCommit != 0) { uint deadline = game.firstCommit + COMMIT_TIMEOUT; if (block.timestamp >= deadline) { return 0; } return int(deadline - block.timestamp); } return int(COMMIT_TIMEOUT); } // Resolve a game that has timed out. Caller must be the non-offending player. // The offending player is slashed (loses their bet), winner gets both bets. function resolveTimeout(uint gameId) public isRegistered(gameId) { GameState storage game = games[gameId]; require(game.isActive, "Game is not active"); address caller = msg.sender; address payable offender; address payable winner = payable(caller); bool commitPhaseTimedOut = game.firstCommit != 0 && block.timestamp > game.firstCommit + COMMIT_TIMEOUT && (game.playerA.encrMove == bytes32(0) || game.playerB.encrMove == bytes32(0)); bool revealPhaseTimedOut = game.firstReveal != 0 && block.timestamp > game.firstReveal + REVEAL_TIMEOUT; if (commitPhaseTimedOut) { // Commit phase timeout: player who didn't commit first is offender require( (caller == game.playerA.addr && game.playerB.encrMove == bytes32(0)) || (caller == game.playerB.addr && game.playerA.encrMove == bytes32(0)), "Caller must be the non-offending player" ); if (caller == game.playerA.addr) { offender = game.playerB.addr; game.outcome = Outcomes.PlayerBTimeout; } else { offender = game.playerA.addr; game.outcome = Outcomes.PlayerATimeout; } } else if (revealPhaseTimedOut) { // Reveal phase timeout: player who didn't reveal is offender require( (caller == game.playerA.addr && game.playerB.move == Moves.None) || (caller == game.playerB.addr && game.playerA.move == Moves.None), "Caller must be the non-offending player" ); if (caller == game.playerA.addr) { offender = game.playerB.addr; game.outcome = Outcomes.PlayerBTimeout; } else { offender = game.playerA.addr; game.outcome = Outcomes.PlayerATimeout; } } else { revert("No timeout has occurred"); } // Reset game resetGame(gameId); // Pay winner and slash offender payWithSlash(winner, offender, game.initialBet); } // ------------------------- Game Management ------------------------- // // Get details of a specific game (for viewing any game) function getGameDetails( uint gameId ) public view returns ( Player memory playerA, Player memory playerB, uint initialBet, Outcomes outcome, bool isActive, uint returnGameId ) { GameState storage game = games[gameId]; require(game.gameId != 0, "Game does not exist"); return ( game.playerA, game.playerB, game.initialBet, game.outcome, game.isActive, game.gameId ); } // Get all active game IDs function getActiveGameIds() public view returns (uint[] memory) { uint activeCount = 0; // Count active games for (uint i = 0; i < gameIds.length; i++) { if (games[gameIds[i]].isActive) { activeCount++; } } // Build array of active game IDs uint[] memory activeIds = new uint[](activeCount); uint index = 0; for (uint i = 0; i < gameIds.length; i++) { if (games[gameIds[i]].isActive) { activeIds[index] = gameIds[i]; index++; } } return activeIds; } }