Inspiration

The inspiration for this project is that one kid that always changes rock to paper at the last second.

Seriously though, the inspiration for this project was to pick a common game, and implement it with an Ethereum smart contract that can't be cheated to demonstrate how versatile smart contracts are, and use some cool mechanics to protect the game's fairness. This simple topic has some cool problems that can apply to many other use cases.

What it does

This is a rock paper scissors game built on the Ethereum blockchain, which makes the game not rely on a main server or authority to decide who wins. A player starts a game by choosing how much to wager on the game (or play without stakes if they prefer). This deploys a contract to the blockchain which represents a rock paper scissors game.

Anyone can join the game by committing the same amount to the contract. After they join, both players send their bets encrypted with a password of their choosing. Once both players have Rock! Paper! Scissor-ed they prove that they made the move they did by sending the decrypted move. If there's a tie, they play again. When finished the contract will automatically pay the winner.

Challenges we ran into

  • If a players moves are public, can't the second player simply see the first's move and make a counter to win?

To protect against this, a timer is set when the second player joins and the game starts. Players will submit their move, combined with a password and hashed (this will happen behind the scenes once there is a proper interface). This way if the other player looks at the transactions they can't take advantage of the other player. (traditionally, this would rely on a web server to verify this. with ethereum, this is all decentralized).

After the timer runs out, or both players have moved, the moves need to be verified and revealed. Both players submit their original move + the hashed version, and if they match up the move is saved.

Accomplishments that we're proud of

Because a cool feature called solidity state channels, there doesn't need to be a transaction submitted for every move, which saves a lot of gas fees. The current use case is pretty limited since there is only one round, but if this project was updated to be a best of 7, or had a lot more moves in between the start and finish of the game (e.g. chess) this would make a really big impact!

What's next for Trust-free Rock Paper Scissors Casino

Create a website so anyone can play!

Open in remix IDE:

Go to the run tab and make sure it's on JS vm. Switch between accounts to simulate two different players.

https://remix.ethereum.org/#version=soljson-v0.8.7+commit.e28d00a7.js&optimize=false&runs=200&gist=a6e76a2f31e6ecef0560cee7cc0632e6

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

contract RPSGame {
    bytes32 constant ROCK = "ROCK";
    bytes32 constant PAPER = "PAPER";
    bytes32 constant SCISSORS = "SCISSORS";

    address public player1;
    address public player2;
    address public gameWinner;
    uint256 public betAmount;
    bool public gameOver;

    struct GameState {
        uint8 seq;
        bytes32 player1commit;
        bytes32 player2commit;
        address whoseTurn;
    }

    GameState public state;
    uint256 public timeoutInterval;
    uint256 public timeout = 2**256 - 1;
    bytes32 public player1move;
    bytes32 public player2move;

    event TimeoutStarted();
    event GameStarted();
    event GameFinished(address player, string move1, string move2);
    event MoveMade(address player, uint8 seq, bytes32 choice);


    // Setup methods

    constructor(uint256 _timeoutInterval) payable {
        player1 = msg.sender;
        betAmount = msg.value;
        timeoutInterval = _timeoutInterval;
    }

    function join() public payable {
        require(player2 == address(0), "Game has already started.");
        require(player1 != msg.sender, "The game owner cannot join their own game");
        require(!gameOver, "Game was canceled.");
        require(msg.value == betAmount, "Wrong bet amount.");

        player2 = msg.sender;
        state.whoseTurn = player1;
        emit GameStarted();

        timeout = block.timestamp + timeoutInterval;
        emit TimeoutStarted();
    }

    function cancel() public {
        require(msg.sender == player1, "Only first player may cancel.");
        require(player2 == address(0), "Game has already started.");

        gameOver = true;
        payable(msg.sender).transfer(address(this).balance);
    }


    // Play methods

    function move(uint8 seq, bytes32 choice) public {
        require(!gameOver, "Game has ended.");
        require(msg.sender == state.whoseTurn, "Not your turn.");
        require(state.seq == seq, "Incorrect sequence number.");
        require(block.timestamp < timeout, "Moves closed"); // Only allow commits during committing period

        // make sure player hasnt played before
        if (msg.sender == player1) {
            require(state.player1commit == bytes32(0), "you have already moved!");
            state.player1commit = choice;
        } else {
            require(state.player2commit == bytes32(0), "you have already moved!");
            state.player2commit = choice;
        }

        // change turn
        state.whoseTurn = opponentOf(msg.sender);
        state.seq += 1;

        emit MoveMade(msg.sender, seq, choice);
    }

    function moveFromState(uint8 seq, bytes memory sig, bytes32 player1commit, bytes32 player2commit, bytes32 choice) public {
        require(seq >= state.seq, "Sequence number cannot go backwards.");

        bytes32 message = prefixed(keccak256(abi.encodePacked(address(this), seq, player1commit, player2commit)));
        require(recoverSigner(message, sig) == opponentOf(msg.sender), "invalid signed message");

        state.seq = seq;
        state.player1commit = player1commit;
        state.player2commit = player2commit;
        state.whoseTurn = msg.sender;

        // now make the move
        move(seq, choice);
    }

    function revealMove(string memory _move, bytes32 _commit) public {
        if (block.timestamp < timeout) {
            require(state.player1commit != bytes32(0) && state.player2commit != bytes32(0), "both players haven't moved yet"); // Only reveal votes after committing period is over
            timeout = block.timestamp;
        }

        // FIRST: Verify the vote & commit is valid
        bytes memory bytesMove = bytes(_move);
        require(_commit != bytes32(0), "must commit a move first");
        require(_commit == keccak256(bytesMove), "move must match commit");

        // NEXT: Count the move!
        require(bytesMove[0] == '0' || bytesMove[0] == '1' || bytesMove[0] == '2', "invalid move");

        bytes32 revealedMove;
        if (bytesMove[0] == '0') {
            revealedMove = ROCK;
        } else if (bytesMove[0] == '1') {
            revealedMove = PAPER;
        } else {
            revealedMove = SCISSORS;
        }

        if (msg.sender == player1) {
            require(_commit == state.player1commit, "commit doesn't match");
            player1move = revealedMove;
            state.player1commit = bytes32(0);
        } else {
            require(_commit == state.player2commit, "commit doesn't match");
            player2move = revealedMove;
            state.player2commit = bytes32(0);
        }
    }

    function getWinner () public returns (address) {
        require(block.timestamp >= timeout, "the move window is still open");
        require(player1move != bytes32(0), "player1 must reveal move");
        require(player2move != bytes32(0), "player2 must reveal move");

        address winner;
        if (player1move == ROCK && player2move == PAPER) {
            winner = player2;
        } else if (player1move == PAPER && player2move == ROCK) {
            winner = player1;
        } else if (player1move == SCISSORS && player2move == PAPER) {
            winner = player1;
        } else if (player1move == PAPER && player2move == SCISSORS) {
            winner = player2;
        } else if (player1move == ROCK && player2move == SCISSORS) {
            winner = player1;
        } else if (player1move == SCISSORS && player2move == ROCK) {
            winner = player2;
        } else {
            // tie
            timeout = block.timestamp + timeoutInterval;
            player1move = bytes32(0);
            player1move = bytes32(0);
            return address(0);
        }

        string memory player1res = string(abi.encodePacked(player1move));
        string memory player2res = string(abi.encodePacked(player2move));

        emit GameFinished(winner, player1res, player2res);

        gameOver = true;
        gameWinner = winner;
        payable(winner).transfer(address(this).balance);
        return winner;
    }

    function opponentOf(address player) internal view returns (address) {
        require(player2 != address(0), "Game has not started.");

        if (player == player1) {
            return player2;
        } else if (player == player2) {
            return player1;
        } else {
            revert("Invalid player.");
        }
    }

    // Signature methods

    function splitSignature(bytes memory sig)
        internal
        pure
        returns (uint8, bytes32, bytes32)
    {
        require(sig.length == 65);

        bytes32 r;
        bytes32 s;
        uint8 v;

        assembly {
            // first 32 bytes, after the length prefix
            r := mload(add(sig, 32))
            // second 32 bytes
            s := mload(add(sig, 64))
            // final byte (first byte of the next 32 bytes)
            v := byte(0, mload(add(sig, 96)))
        }

        return (v, r, s);
    }

    function recoverSigner(bytes32 message, bytes memory sig)
        internal
        pure
        returns (address)
    {
        uint8 v;
        bytes32 r;
        bytes32 s;

        (v, r, s) = splitSignature(sig);

        return ecrecover(message, v, r, s);
    }

    // Builds a prefixed hash to mimic the behavior of eth_sign.
    function prefixed(bytes32 hash) internal pure returns (bytes32) {
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
    }
}

Built With

Share this project:

Updates