DEV Community

milaabl
milaabl

Posted on

On-Chain dApp Game with Solidity, React, Typescript & Wagmi/Viem (Commit-reveal keccak256, contract factory patterns)

This dApp is an on-chain rock paper scissors lizard spock game built with React, Typescript, Wagmi/Viem, Sepolia & integrated with Solidity smart contracts (contract factory, commit/reveal patterns).

We’ll utilize the best practices, follow the Solidity security patterns & the reusable components approach to React and Typescript development.

The final version of the dApp we’ll be building in this tutorial looks like this: https://rpsls-medium-tutorial.vercel.app

You can try it out yourself before going deeper in the implementation details, because it’ll help you with better understanding the user flows & logic of the game.

I’ll also show you how to implement common Solidity patterns such as:

  • commit-reveal;
  • contract factory;

We’ll focus on security, and also adhere to old-fashioned Solidity practices such as the checks -> effects -> interactions pattern.

I’ll start off by scaffolding a new React & Typescript project:

yarn create react-app rpsls-game --template typescript

Next we need to install the necessary NPM dependencies:

cd rpsls-game
yarn add @mui/material @mui/styled-engine-sc styled-components @fontsource/red-hat-display viem wagmi react-router-dom react-timer-hook

Now let’s build a Solidity contract that we’ll be using as main game juror & that will lock the rewards for the players:

// SPDX-License-Identifier: Unlicensed

pragma solidity ^0.8.12;

contract RPSLS {
    enum Move {
        Null, Rock, Paper, Scissors, Lizard, Spock
    }
    address public player1;
    address public player2;

    bytes32 move1Hash;

    Move public move1;
    Move public move2;

    uint256 public stake;

    uint256 public TIMEOUT_IN_MS = 5 minutes;
    uint256 public lastTimePlayed;

    modifier onlyOwner() {
        require(msg.sender == player1);
        _;
    }

    event Player2Played(address indexed _player2, Move indexed _move2);
    event GameSolved(address indexed winner);
    event GameTied();
    event GameTimedOut(address indexed fallbackWinner);

    constructor(bytes32 _move1Hash, address _player1, address _player2) payable {
        stake = msg.value;
        move1Hash = _move1Hash;
        player1 = _player1;
        player2 = _player2;
        lastTimePlayed = block.timestamp;
    }

    function play (Move _move2) external payable {
        require(msg.value == stake, "Insufficient funds for move. Make sure you stake the required amount of ETH for the transaction to succeed.");
        require(msg.sender == player2);
        require(move2 == Move.Null, "Move already played");

        move2 = _move2;
        lastTimePlayed = block.timestamp;

        emit Player2Played(player2, _move2);
    }

    function solve(Move _move1, string calldata _salt) onlyOwner external {
        require(player2 != address(0), "Player 2 should make his move in order to solve the round.");
        require(move2 != Move.Null, "Player 2 should move first.");
        require(keccak256(abi.encodePacked(_move1, _salt)) == move1Hash, "The exposed value is not the hashed one!");
        require(stake > 0, "Winner is already determined.");

        move1 = _move1;

        uint256 _stake = stake;

        if (win(move1, move2)) {
            stake = 0;
            (bool _success) = payable(player1).send(2 * _stake);
            if (!_success) {
                stake = _stake;
            }
            else {
                emit GameSolved(player1);
            }
        }
        else if (win(move1, move2)) {
            stake = 0;
            (bool _success) = payable(player2).send(2 * _stake);
            if (!_success) {
                stake = _stake;
            }
            else {
                emit GameSolved(player2);
            }
        }
        else {
            stake = 0;
            (bool _success1) = payable(player2).send(_stake);
            (bool _success2) = payable(player1).send(_stake);
            if (!(_success1 || _success2)) {
                stake = _stake;
            }
            else {
                emit GameTied();
            }
        }
    }

    function win(Move _move1, Move _move2) public pure returns (bool) {
        if (_move1 == _move2)
            return false; // They played the same so no winner.
        else if (_move1 == Move.Null)
            return false; // They did not play.
        else if (uint(_move1) % 2 == uint(_move2) % 2) 
            return (_move1 < _move2);
        else
            return (_move1 > _move2);
    }

    function claimTimeout() external {
        require(msg.sender == player1 || msg.sender == player2, "You're not a player of this game.");

        require(block.timestamp > lastTimePlayed + TIMEOUT_IN_MS, "Time has not run out yet.");

        uint256 _stake = stake;
        stake = 0;

        if (player2 == address(0)) {
            (bool _success) = payable(player1).send(_stake);
            if (!_success) {
                stake = _stake;
            }
            else {
                emit GameTimedOut(player1);
            }
        }
        else if (move2 != Move.Null) {
         (bool _success) = payable(player2).send(_stake * 2);
            if (!_success) {
                stake = _stake;
            }
            else {
                emit GameTimedOut(player2);
            }   
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

To make sure the game cannot be cheated by a front-running attack or block explorer attack, I’m using the commit-reveal startegy pattern.

The first player’s move will remain hashed until the second player makes his move & the player 1 solves the game by revealing his move. There’s a good article about that pattern from the O’Reilly Team. The algoritgm uses the keccak256 hasher implementation.

Now take a look at our contract’s code.

Did you notice what’s missing?

Currently the main game contract can only be deployed with a method like viem’s publicClient.deployContract().

However, the address of the deployed contract will not be stored anywhere, and the only way the user can retrieve the deployed contract’s address after further leaving the page is by exloring the transactions tab of his Metamask wallet.

It’s definitely not user-friendly.

Let’s write an additional contract that will act as a factory producing & storing new game session contracts, each attached to the players’ addresses.

// SPDX-License-Identifier: Unlicensed

pragma solidity ^0.8.12;
import "./RPSLS.sol"

contract RPSLSFactory {
    RPSLS[] private gameSessions;
    mapping (address => RPSLS[]) private userGameSessions;

    event NewGameSession(address indexed gameSession);
    function createGameSession(
        bytes32 _move1Hash,
        address _player2
    ) external payable {
        RPSLS gameSession = (new RPSLS){value: msg.value}(
            _move1Hash,
            msg.sender,
            _player2
        );
        gameSessions.push(gameSession);
        userGameSessions[msg.sender].push(gameSession);
        userGameSessions[_player2].push(gameSession);

        emit NewGameSession(address(gameSession));
    }
    function getGameSessions()
        external
        view
        returns (RPSLS[] memory _gameSessions)
    {
        return userGameSessions[msg.sender];
    }
}
Enter fullscreen mode Exit fullscreen mode

✅ I’ll be using the Sepolia 🐬testnet for the further development & contract deployment.

👉 OK, now when we have implemented the necessary Solidity code, we can continue with our React app.

❗Make sure you save the deployed address of the RPSLSFactory.sol contract & add it to your .env file.

// .env

REACT_APP_PUBLIC_RPSLS_FACTORY_ADDRESS=<your factory's deployed address>
Enter fullscreen mode Exit fullscreen mode
import React from 'react';
import './App.css';
import { WagmiConfig, configureChains, createConfig } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { publicProvider } from 'wagmi/providers/public'
import { InjectedConnector } from 'wagmi/connectors/injected'

const { chains, publicClient } = configureChains(
  [sepolia],
  [publicProvider()]
)

const connector = new InjectedConnector({
  chains,
})

const config = createConfig({
  publicClient,
  connectors: [connector],
  autoConnect: true,
});

function App() {
  return (
    <WagmiConfig config={config}>

    </WagmiConfig>
  );
}
Enter fullscreen mode Exit fullscreen mode

The basic config for your App.tsx file should look like this 👆.

I figured out that using the publicProvider with a testnet might sometimes be a culprit when fetching pending transaction details, so I switched to using the alchemyProvider & separated the wagmi config to a new file:

// wagmi.ts ~ App.tsx

import { configureChains, createConfig } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { alchemyProvider } from 'wagmi/providers/alchemy'
import { MetaMaskConnector } from 'wagmi/connectors/metaMask'

const { chains, publicClient, webSocketPublicClient } = configureChains(
  [sepolia],
  [alchemyProvider({
    apiKey: process.env.REACT_APP_PUBLIC_ALCHEMY_API_KEY
  })]
)

const connector = new MetaMaskConnector({
  chains,
})

export const wagmiConfig = createConfig({
  publicClient,
  webSocketPublicClient,
  connectors: [connector]
});
Enter fullscreen mode Exit fullscreen mode

👉 Let’s create a contracts.ts file in the src/ directory. It’ll be used to store the address of the factory contract, as well as its ABI — so that we connect the contract the reusable way, so we don’t reference the process.env.REACT_APP_PUBLIC_RPSLS_FACTORY_ADDRESS variable each time, as this project is using Typescript, we’ll also need to use some type-casting like as Address, where Address is a type provided by Wagmi.

// contracts.ts

import { Address } from "wagmi";

export const contracts = {
    factory: {
        address: process.env.REACT_APP_PUBLIC_RPSLS_FACTORY_ADDRESS as Address,
        abi: [
          ...
        ]
      },
      rpslsGame: {
        abi: [...]
      }
};
Enter fullscreen mode Exit fullscreen mode

💎 I’m using @mui/material & styled-components for my project, you can use any components library you wish. For the purposes of this tutorial, I’ll mainly omit the stylization code so that it doesn’t mess with the Web3 integration part.

Web3 Front-end integration

We’ll need the useAccount, useSwitchNetwork, useConnect, useNetwork, useDisconnect, useContractWrite, useContractRead, useContractReads & useWaitForTransaction hooks provided by the wagmi-dev/wagmi package.

@mui/material, styled-components, React, Typescript basic layout

Image description

Network switch component & indicator

  1. Let’s first build the wallet connection functionality & UI like the above 👆👆👆. The header will have:
  • a connect button
  • a loading indicator
  • a connected account label.

The best practice is to also have a <SwitchNetwork /> component that will offer the user the ability to switch his current network to one of the dApp’s supported networks.

The Header.tsx component:

// Header.tsx

import React from "react";
import { useAccount, useConnect, useDisconnect } from "wagmi";
import CancelIcon from '@mui/icons-material/Cancel';
import * as S from "./Header.styles";
import logoIcon from 'assets/icons/logo.svg';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import SwitchNetwork from './SwitchNetwork';
import { IconButton } from "@mui/material";

function Header () {
    const { isLoading : isConnectingWallet, connectors, connect } = useConnect();
    const { address } = useAccount();
    const { disconnect } = useDisconnect();
    return <>
        <S.Header>
            <S.Logo alt="Logo" src={logoIcon} />
            { !address ? <S.Button loading={isConnectingWallet} onClick={() => connect({
                connector: connectors[0]
            })} startIcon={<AccountCircleIcon />}>Connect wallet</S.Button> : <S.AccountAddress>
                <IconButton size="small" onClick={() => disconnect()}>
                    <CancelIcon color="error" fontSize="inherit" />
                </IconButton>
                <span>{address}</span></S.AccountAddress> }
        </S.Header>
        <SwitchNetwork />
    </>
};

export default Header;
Enter fullscreen mode Exit fullscreen mode

The SwitchNetwork.tsx component:

// SwitchNetwork.tsx

import React from "react";
import { useSwitchNetwork, useNetwork, Chain } from "wagmi";
import * as S from './SwitchNetwork.styles';

function SwitchNetwork () {
    const { switchNetwork, chains } = useSwitchNetwork();
    const { chain } = useNetwork();
    return <S.Container>
        {chains && switchNetwork && !chains.find((supportedChain : Chain) => supportedChain.id === chain?.id) ? <S.SwitchButton onClick={
            () => switchNetwork(chains[0]?.id)
        }>Switch to {chains[0]?.name}</S.SwitchButton> : <></>}
    </S.Container>
}

export default SwitchNetwork;
Enter fullscreen mode Exit fullscreen mode
  1. OK. The user can now connect his Metamask wallet, disconnect it if he wants, switch the network of his Metamask account if the network he’s using is not on the dApp’s supported networks list.

Let’s now start the work on our front-end RPSLSFactory.sol integration by building the components for the new-game page. The user will be able to set a game’s bid, select his first move, hash it & create a new instance of game by inviting the 2nd player.

Create a new rock paper scissars lizard spock game session React + Typescript + Web3 (Wagmi/Viem) page 🎮

The request to write a contract looks like the following in Wagmi:

const {
    error,
    isLoading: isNewGameSessionLoading,
    write: createNewGameSession,
    data: createNewGameSessionData,
  } = useContractWrite({
    ...contracts.factory,
    functionName: "createGameSession",
    value: parseEther(bid),
  });
Enter fullscreen mode Exit fullscreen mode

Note that I’m not passing the “args” param in the useContractWrite hook because I’ll pass it later when making a call. As the move hash is generated asynchronously, I found that it’s a more convenient way.

🦉 It’s a good practice to let the user paste the opponent’s address clicking on the button.

Let’s also add the move icons so the user can selected his move:

Decentralized rock paper scissors lizard spock game in React, Typescript, Web3

🔐 Now I’ll show you how to securely encrypt the user’s move. As our Solidity contract is using the keccak256 function, we’ll be using an equivalent function provided by viem.

🦉 I used to reference the etherssolidityKeccak256 function, but viem has its own alternative. Let’s find out how to generate a secret key & hash it.

That’s basically the main commitment encryption code we’ll need in order to call the factory contract’s function:

const salt = crypto.randomUUID();

const _move1Hash = keccak256(
  encodePacked(["uint8", "string"], [selectedMove, salt]),
);

_salt.current = salt;

createNewGameSession({
  args: [_move1Hash, player2],
});
Enter fullscreen mode Exit fullscreen mode

Here I’m using the Browser Subtle Crypto API & the keccak256 from the viem library.

But first let’s make sure that the user signs our actions & verifies his commitment. We’ll need to use the useSignMessage hook to verify that the user is OK with using a randomly generated salt to initiate a new game.

const { signMessage, data: signData } = useSignMessage();

...

signMessage({ message: `Your game move is: ${selectedMove}. Your game salt is: ${_salt.current}. Keep it private! It'll automatically be stored in your local storage.` });
Enter fullscreen mode Exit fullscreen mode

The final code of the “Create new game” page looks like this:

// pages/new-game/index.tsx

import React, {
  ChangeEvent,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";
import {
  useContractWrite,
  useAccount,
  useSignMessage,
} from "wagmi";
import { useLocalStorage } from "hooks/useLocalStorage";
import * as S from "./styles";
import { moves, moveIcons, Move } from "moves";
import { contracts } from "contracts";
import { Hash, encodePacked, keccak256, parseEther } from "viem";
import { AppContext } from "context/AppContext";
import TransactionHistory from "components/TransactionHistory/TransactionHistory";
import { validateAddress } from "utils/validators";

function NewGamePage() {
  const { address } = useAccount();

  const [player2, setPlayer2] = useState<string | undefined>();
  const [bid, setBid] = useState<string>("0");

  const [, setSalt] = useLocalStorage("salt");
  const [, setMove1] = useLocalStorage("move");

  const [selectedMove, setSelectedMove] = useState<Move>(Move.Null);

  const [isMoveCommitted, setIsMoveCommitted] = useState<boolean>(false);

  const {
    error,
    isLoading: isNewGameSessionLoading,
    write: createNewGameSession,
    data: createNewGameSessionData,
  } = useContractWrite({
    ...contracts.factory,
    functionName: "createGameSession",
    value: parseEther(bid),
  });

  const _salt = useRef<string | undefined>();
  const _move1Hash = useRef<string | undefined>();

  const { signMessage, data: signData } = useSignMessage();

  useEffect(() => {
    if (createNewGameSessionData?.hash && !error) {
      setIsMoveCommitted(true);
    }
  }, [createNewGameSessionData?.hash]);

  const [gameSessionHash, setGameSessionHash] = useState<Hash>();

  useEffect(() => {
    if (!createNewGameSessionData && signData) createNewGameSession({
      args: [_move1Hash.current, player2],
    });
  }, [signData]);

  useEffect(() => {
    if (gameSessionHash && _salt.current) {
      setSalt(_salt.current, `salt-${gameSessionHash}`);
      setMove1(String(selectedMove), `move-${gameSessionHash}`);
    }
  }, [
    gameSessionHash
  ]);

  const { setErrorMessage, setIsLoading } = useContext(AppContext);

  useEffect(() => {
    error?.message && setErrorMessage?.(error.message);
  }, [error?.message]);

  useEffect(() => {
    setIsLoading?.(isNewGameSessionLoading);
  }, [isNewGameSessionLoading]);

  return !isMoveCommitted ? (
    <S.Container>
      <S.MovesContainer>
        {moves.map((move: Move) => (
          <S.MoveItem
            className={selectedMove === move ? "selected" : ""}
            onClick={() => setSelectedMove(move)}
          >
            <img src={moveIcons[move - 1]} alt={`Move №${move}`} />
          </S.MoveItem>
        ))}
      </S.MovesContainer>
      <S.Heading>Create a new game session 🎮</S.Heading>
      <S.Form>
        <S.Input>
          <S.TextField
            inputProps={{
              maxLength: 42,
            }}
            InputLabelProps={{ shrink: true }}
            label="Address of player2's wallet"
            helperText="Invite your opponent 🪖"
            value={player2}
            onChange={({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
              setPlayer2(value)
            }
          />
          <S.PasteWalletAddressButton
            label="Paste"
            onClick={() => {
              navigator.clipboard.readText().then((value) => setPlayer2(value));
            }}
          />
        </S.Input>
        <S.Input>
          <S.TextField
            inputProps={{
              step: "0.01",
            }}
            type="number"
            label="Bid (in ETH)"
            helperText="Please enter the bid 🎲 for the game"
            value={bid}
            onChange={({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
              setBid(value)
            }
          />
        </S.Input>
      </S.Form>
      <S.SubmitButton
        disabled={
          !(
            address &&
            selectedMove !== Move.Null &&
            Number(bid) > 0 &&
            validateAddress(player2)
          )
        }
        onClick={() => {
          const salt = crypto.randomUUID();

          _move1Hash.current = keccak256(
            encodePacked(["uint8", "string"], [selectedMove, salt]),
          );

          _salt.current = salt;

          signMessage({ message: `Your game move is: ${selectedMove}. Your game salt is: ${_salt.current}. Keep it private! It'll automatically be stored in your local storage.` });
        }}
      >
        Submit session ✅
      </S.SubmitButton>
    </S.Container>
  ) : createNewGameSessionData?.hash ? (
    <TransactionHistory
      setGameSessionHash={setGameSessionHash}
      transactionHash={createNewGameSessionData?.hash}
    />
  ) : (
    <></>
  );
}

export default NewGamePage;
Enter fullscreen mode Exit fullscreen mode

⚠️ We are storing the salt & player’s move in the localStorage. Currently the auth stuff is a bit complicated in the Web3, but the use of localStorage in case of our game helps us further solve the game by revealing the first player’s commitment, so that he doesn’t need to store it himself.

Optionally, you can incorporate Metamask’s recent feature that allows storing passwords in the local self-custodial wallet the safe way: https://github.com/ritave/snap-passwordManager.

I thought of implementing it in this tutorial application, but not every user has the development version of the Metamask extension installed, so I might leave it for the next article. 😉

You can checkout the Metamask’s Snap edition: https://metamask.io/news/developers/invisible-keys-snap-multi-cloud-private-key-storage.

❗Notice that as the dApp alllows multiple game session at a time, we have to store the salt & move values attached to more specific keys like “salt” -> “salt-0x3800429c6cFB510602eb9545D133b046B9d19535”, “move” -> “move-0x3800429c6cFB510602eb9545D133b046B9d19535”. Otherwise, it’ll be hard to figure out which salt & move are related to a particular game session.

💁‍♀️ I also created a context with the React Context API in order to have the loading spinner functionality in the app, for better UX.

Wagmi useContractWrite, useWaitForTransaction, usePrepareContractWrite loader

Let’s also add a transaction history component.

It’ll wait for the transaction to complete & show the details.

// components/TransactionHistory/TransactionHistory.tsx

import React, { Dispatch, useContext, useEffect, useState } from "react";
import * as S from "./TransactionHistory.styles";
import { Address, useWaitForTransaction } from "wagmi";
import { Hash, decodeAbiParameters } from "viem";
import { useTheme } from "@mui/material";
import successTickIcon from "assets/icons/success-tick.svg";
import { AppContext } from "context/AppContext";
import { useNavigate } from "react-router-dom";

interface TransactionHistoryProps {
  transactionHash: Hash;
  setGameSessionHash: Dispatch<Hash>;
}

function TransactionHistory({
  setGameSessionHash: _setGameSessionHash,
  transactionHash: _transactionHash,
}: TransactionHistoryProps) {
  const theme = useTheme();

  const [transactionHash, setTransactionHash] =
    useState<Hash>(_transactionHash);

  const { setIsLoading, setErrorMessage } = useContext(AppContext);

  const {
    error,
    data: transactionData,
    isLoading: isTransactionDataLoading,
  } = useWaitForTransaction({
    hash: transactionHash,
    enabled: !!transactionHash,
    onSuccess: (data) => {
      console.log(data);
    },
    onReplaced: (replacement) => {
      console.log({ replacement });
      if (replacement.reason === "cancelled") {
        setErrorMessage?.(`Transaction ${transactionHash} was cancelled`);
        return;
      } else {
        setTransactionHash(replacement.transactionReceipt.transactionHash);
      }
    },
    onError: (err) => setErrorMessage?.(err.message),
    confirmations: 1,
  });

  const [gameSessionHash, setGameSessionHash] = useState<Hash>();

  useEffect(() => {
    if (!transactionData?.logs[0]?.data) return;

    const _gameSessionHash = String(
      decodeAbiParameters(
        [
          {
            type: "address",
            name: "gameSession",
          },
        ],
        transactionData.logs[0].topics[1] as Address,
      ),
    ) as Hash;

    setGameSessionHash(_gameSessionHash);

    _setGameSessionHash(_gameSessionHash);
  }, [transactionData]);

  const navigate = useNavigate();

  useEffect(() => {
    setIsLoading?.(isTransactionDataLoading);
  }, [isTransactionDataLoading]);

  return isTransactionDataLoading ? (
    <>
      <S.Container>
        <S.Details>
          <S.Heading>Transaction pending</S.Heading>
          <S.DetailsItem>
            <strong>Transaction address: </strong>
            {transactionHash}
          </S.DetailsItem>
          <S.DetailsItem>
            <strong>Status: </strong>
            Pending
          </S.DetailsItem>
          <S.LoadingIconComponent variant="indeterminate" />
        </S.Details>
        <S.CutOffBorder />
      </S.Container>
    </>
  ) : transactionData?.status === "success" && !error ? (
    <>
      <S.Container>
        <S.Details>
          <S.SuccessIndicator
            height={theme.spacing(4)}
            src={successTickIcon}
            alt="Success"
          />
          <S.Heading>Transaction details</S.Heading>
          <S.DetailsItem>
            <strong>Transaction address: </strong>
            {transactionData?.transactionHash}
          </S.DetailsItem>
          <S.DetailsItem>
            <strong>Gas used: </strong>
            {String(transactionData.gasUsed)}
          </S.DetailsItem>
          <S.DetailsItem>
            <strong>Gas price: </strong>
            {String(transactionData.effectiveGasPrice)} WEI
          </S.DetailsItem>
          {gameSessionHash ? (
            <S.DetailsItem>
              <strong>Game session hash: </strong>
              {gameSessionHash}
            </S.DetailsItem>
          ) : (
            <></>
          )}
          <S.DetailsItem>
            <strong>Status: </strong>
            {transactionData.status.charAt(0).toUpperCase()}
            {transactionData.status.slice(1)}
          </S.DetailsItem>
        </S.Details>
        <S.CutOffBorder />
      </S.Container>
      {gameSessionHash ? (
        <S.GameButtonsContainer>
          <S.InviteOpponentButton
            onClick={() => {
              navigator.clipboard.writeText(
                `${window.location.hostname}/game-session/${gameSessionHash}`,
              );
            }}
          >
            Copy opponent's invitation link
          </S.InviteOpponentButton>
          <S.GoToSolveGameButton
            onClick={() => navigate(`/game-session/${gameSessionHash}`)}
          >
            Go to game session
          </S.GoToSolveGameButton>
        </S.GameButtonsContainer>
      ) : (
        <></>
      )}
    </>
  ) : (
    <></>
  );
}

export default TransactionHistory;
Enter fullscreen mode Exit fullscreen mode
onReplaced: (replacement) => {
      console.log({ replacement });
      if (replacement.reason === "cancelled") {
        setErrorMessage?.(`Transaction ${transactionHash} was cancelled`);
        return;
      } else {
        setTransactionHash(replacement.transactionReceipt.transactionHash);
      }
    },
Enter fullscreen mode Exit fullscreen mode

👆 This particular lines allow us to seamlessly replace the transaction hash & continue fetching the data if the transaction gets replaced with a higher fee to speed up.

Bonus! Some utils🧹 worth mentioning:

// hooks/useLocalStorage.ts

import React, { useEffect, useSyncExternalStore } from "react";

export const useLocalStorage = (
  key: string,
): [string | null, (value: string, key?: string) => void] => {
  const subscribe = (listener: () => void) => {
    window.addEventListener("storage", listener);
    return () => {
      window.removeEventListener("storage", listener);
    };
  };

  const getSnapShot = (): string | null => {
    return localStorage.getItem(key);
  };

  const value = useSyncExternalStore(subscribe, getSnapShot);

  const setValue = (newValue: string, _key?: string) => {
    localStorage.setItem(_key || key, newValue);
  };

  useEffect(() => {
    if (value) {
      setValue(value, key);
    }
  }, [key]);

  return [value, setValue];
};
Enter fullscreen mode Exit fullscreen mode
// utils/validators.ts

import { isAddress } from "viem";

export const validateAddress = (address : string | undefined) => address && isAddress(address);
Enter fullscreen mode Exit fullscreen mode

There’re a lot of libraries that provide the useLocalStorage hook, but they don’t have support for updating the key of the storage item. As our salt & move storage secrets are attached to a gameSession address for further decryption, we need the key to be a dynamically updating variable.

  1. The second page will be the index welcome page where all of the games available to the user will be displayed, either a game where the user is the 1st player or has an invite to join the game as the 2nd player.

It’s a pretty simple page except for one nuance.

We have to make sure that if the game is gets further solved, it won’t be displayed in the available game sessions list, as it’s not active anymore.

I used the useContractRead hook to fetch the user’s available game sessions from the factory 👩‍🏭contract.

Then I mapped through the returned array to fetch the values of stakes of each game session. I used the useContractReads hook to read from a list of contracts.

import { AppContext } from "context/AppContext";
import * as S from "./styles";
import { contracts } from "contracts";
import React, { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Abi, Hash, MulticallResult } from "viem";
import { useWalletClient, useContractRead, useContractReads } from "wagmi";

function WelcomePage() {
  const { data: walletClient } = useWalletClient();
  const { isLoading, data: availableGameSessions } = useContractRead({
    ...contracts.factory,
    functionName: "getGameSessions",
    account: walletClient?.account,
    watch: true,
    select: (data: any) => {
      return data as Array<Hash>;
    },
  });

  const [activeGameSessions, setActiveSessions] = useState<Array<Hash>>([]);

  const { isLoading: isGameStakesLoading } = useContractReads({
    contracts: (availableGameSessions as Array<Hash>)?.map(
      (gameSession: Hash) => {
        return {
          address: gameSession,
          functionName: "stake",
          abi: contracts.rpslsGame.abi as Abi,
        };
      },
    ),
    onSuccess: (data) => {
      const contractStakes = (
        data as unknown as Array<MulticallResult<bigint>>
      ).map((result: MulticallResult<bigint>) => result.result);
      contractStakes.forEach(
        (contractStake: bigint | undefined, index: number) => {
          if (
            Number(contractStake as unknown as bigint) > 0 &&
            availableGameSessions?.[index]
          ) {
            setActiveSessions((prev: Array<Hash>) =>
              Array.from(new Set([...prev, availableGameSessions[index]])),
            );
          }
        },
      );
    },
    watch: true,
  });

  const navigate = useNavigate();

  const { setIsLoading } = useContext(AppContext);

  useEffect(() => {
    setIsLoading?.(isLoading || isGameStakesLoading);
  }, [isLoading, isGameStakesLoading]);

  return activeGameSessions &&
    (activeGameSessions as Array<Hash>)?.length >= 1 ? (
    <S.Container>
      {(activeGameSessions as Array<Hash>).map((hash: Hash) => (
        <S.LinkToSession onClick={() => navigate(`/game-session/${hash}`)}>
          <span>{hash}</span>
          <S.ArrowRightButton />
        </S.LinkToSession>
      ))}
      <S.NewGameSessionLink onClick={() => navigate("/new-game")}>
        Propose new game session
      </S.NewGameSessionLink>
    </S.Container>
  ) : (
    <S.NoAvailableGameSessions>
      <S.NoAvailableGameSessionsLabel>
        There're no available active game sessions for you yet. Propose a new
        game session or get invited to join one!
      </S.NoAvailableGameSessionsLabel>
      <S.NewGameSessionLink onClick={() => navigate("/new-game")}>
        Propose new game session
      </S.NewGameSessionLink>
    </S.NoAvailableGameSessions>
  );
}

export default WelcomePage;
Enter fullscreen mode Exit fullscreen mode
  1. The last page is the game session page. It’ll be accessible to the players of the same game session, for the 2nd user to join the game & make his move, the first player can then reveal his commitment, as well there’s timeout functionality incorporated. It’s a shareable link, thanks to the react-router-dom's route template path we can reference the unique gameSession hash from the /game-session/:gameSession location parameter.

The game session page is probably the most complex page of this app.

Let me show you the full code & then I’ll explain it step-by-step.

// pages/game-session/index.tsx

import { contracts } from "contracts";
import * as S from "./styles";
import hiddenMoveIcon from "assets/icons/moves/hidden-move.gif";
import React, { useContext, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import {
  Address,
  useAccount,
  useContractRead,
  useContractWrite,
  useWaitForTransaction,
} from "wagmi";
import { Move, moveIcons, moves } from "moves";
import { formatEther } from "viem";
import { useTimer } from "react-timer-hook";
import { AppContext } from "context/AppContext";
import { useLocalStorage } from "hooks/useLocalStorage";
import { Typography } from "@mui/material";

interface ContractData {
  abi: any;
  address: Address;
}

const formatTime = (time: number): string =>
  time < 10 ? `0${time}` : `${time}`;

function GameSessionPage() {
  const { hash } = useParams();

  const rpslsGameContract: ContractData = {
    abi: contracts.rpslsGame.abi,
    address: hash as Address,
  };

  const { data: move2 } = useContractRead({
    ...rpslsGameContract,
    functionName: "move2",
    watch: true,
  });

  const { data: stake } = useContractRead({
    ...rpslsGameContract,
    functionName: "stake",
    watch: true,
  });

  const { data: player1 } = useContractRead({
    ...rpslsGameContract,
    functionName: "player1",
    watch: true,
  });

  const { data: lastTimePlayed } = useContractRead({
    ...rpslsGameContract,
    functionName: "lastTimePlayed",
    watch: true,
  });

  const { data: TIMEOUT_IN_MS } = useContractRead({
    ...rpslsGameContract,
    functionName: "TIMEOUT_IN_MS",
  });

  const { data: player2 } = useContractRead({
    ...rpslsGameContract,
    functionName: "player2",
    watch: true,
  });

  const { setIsLoading } = useContext(AppContext);

  const [isEligibleForTimeout, setIsEligibleForTimeout] =
    useState<boolean>(false);

  const {
    isLoading: claimTimeoutLoading,
    write: claimTimeout,
    data: claimTimeoutTransactionData,
  } = useContractWrite({
    ...rpslsGameContract,
    functionName: "claimTimeout",
  });

  const { address } = useAccount();

  const [successMessage, setSuccessMessage] = useState<string | undefined>();

  const { isLoading: claimTimeoutTransactionLoading } = useWaitForTransaction({
    hash: claimTimeoutTransactionData?.hash,
    onSuccess: () => setSuccessMessage("Timeout claimed successfully"),
  });

  useEffect(() => {
    setIsLoading?.(claimTimeoutLoading || claimTimeoutTransactionLoading);
  }, [claimTimeoutLoading, claimTimeoutTransactionLoading]);

  const { seconds, minutes, restart } = useTimer({
    expiryTimestamp: new Date(
      ((Number(lastTimePlayed || 0) as unknown as number) +
        (Number(TIMEOUT_IN_MS || 0) as unknown as number)) *
        1000,
    ),
    autoStart: true,
    onExpire: () => setIsEligibleForTimeout(true),
  });

  const [selectedMove, setSelectedMove] = useState<Move>(Move.Null);

  const {
    write: submitMove,
    isLoading: isSubmitMoveLoading,
    data: submitMoveData,
  } = useContractWrite({
    ...rpslsGameContract,
    functionName: "play",
    args: [selectedMove],
    value: stake as unknown as bigint,
  });

  const { isLoading: isSubmitMoveTransactionLoading } = useWaitForTransaction({
    hash: submitMoveData?.hash,
    onSuccess: () => setSuccessMessage("Move submitted successfully!"),
  });

  const [salt] = useLocalStorage(`salt-${hash}`);
  const [move1] = useLocalStorage(`move-${hash}`);

  const {
    write: solveGame,
    isLoading: isSolveGameLoading,
    data: solveGameData,
  } = useContractWrite({
    ...rpslsGameContract,
    functionName: "solve",
    args: [Number(move1), salt],
  });

  const { isLoading: isSolveGameTransactionLoading } = useWaitForTransaction({
    hash: solveGameData?.hash,
    onSuccess: () =>
      setSuccessMessage("Game solved successfully. See the winner! 🎊🎉"),
  });

  const { data: isPlayer1Winner } = useContractRead({
    ...rpslsGameContract,
    functionName: "win",
    args: [move1, move2],
    enabled: Number(move1) !== Move.Null && Number(move2) !== Move.Null,
  });

  const { data: isPlayer2Winner } = useContractRead({
    ...rpslsGameContract,
    functionName: "win",
    args: [move2, move1],
    enabled: Number(move1) !== Move.Null && Number(move2) !== Move.Null,
  });

  useEffect(() => {
    if (!TIMEOUT_IN_MS || !lastTimePlayed) return;

    restart(
      new Date(
        ((Number(lastTimePlayed || 0) as unknown as number) +
          (Number(TIMEOUT_IN_MS || 0) as unknown as number)) *
          1000,
      ),
    );
  }, [TIMEOUT_IN_MS, lastTimePlayed]);

  return player1 && (stake || !move2) ? (
    <S.Container>
      {player2 === address ? (
        <S.MovesContainer>
          {moves.map((move: Move) => (
            <S.MoveItem
              className={move === selectedMove ? "selected" : ""}
              onClick={() => setSelectedMove(move)}
            >
              <img src={moveIcons[move - 1]} alt={`Move №${move}`} />
            </S.MoveItem>
          ))}
        </S.MovesContainer>
      ) : (
        <></>
      )}
      <S.PlayerContainer>
        <S.DetailsItem>
          <strong>Player 1: </strong>
          <span>{player1 as unknown as Address}</span>
        </S.DetailsItem>
        <S.DetailsItem>
          <strong>Player 2: </strong>
          <span>{player2 as unknown as Address}</span>
        </S.DetailsItem>
        <S.DetailsItem>
          <strong>Stake details: </strong>
          <span>{formatEther(stake as unknown as bigint)} ETH</span>
        </S.DetailsItem>
        <S.DetailsItem>
          <strong>Time until timeout: </strong>
          <span>
            {formatTime(minutes)}::{formatTime(seconds)}
          </span>
        </S.DetailsItem>
        <S.DetailsItem>
          <strong>Player 1's move: </strong>
          <S.HiddenMoveImage src={hiddenMoveIcon} alt="?" />
        </S.DetailsItem>
        <S.DetailsItem>
          <strong>Player 2's move: </strong>
          {player2 === address && !move2 ? (
            <S.SubmitMoveButton
              disabled={selectedMove === Move.Null}
              onClick={() => submitMove?.()}
              loading={isSubmitMoveLoading || isSubmitMoveTransactionLoading}
            >
              Submit move
            </S.SubmitMoveButton>
          ) : (move2 as unknown as Move) === Move.Null ? (
            <span>Move not submitted</span>
          ) : (
            <S.MoveImage
              alt="Player 2's move"
              src={moveIcons[(move2 as unknown as Move) - 1]}
            />
          )}
        </S.DetailsItem>
      </S.PlayerContainer>
      {((player2 as unknown as Address) === address &&
        move2 &&
        isEligibleForTimeout) ||
      (isEligibleForTimeout &&
        (player1 as unknown as Address) === address &&
        !move2) ? (
        <S.TimeoutButton
          loading={claimTimeoutLoading || claimTimeoutTransactionLoading}
          onClick={() => claimTimeout?.()}
        >
          Claim timeout
        </S.TimeoutButton>
      ) : (
        <></>
      )}
      {(player1 as unknown as Address) === address && move2 ? (
        <S.SolveButton
          loading={isSolveGameTransactionLoading || isSolveGameLoading}
          onClick={() => solveGame?.()}
        >
          Solve game
        </S.SolveButton>
      ) : (
        <></>
      )}
      {successMessage ? <S.SuccessBox>{successMessage}</S.SuccessBox> : <></>}
    </S.Container>
  ) : player1 && move2 ? (
    <S.GameSolvedContainer>
      <Typography variant="h3">Game solved successfully!</Typography>
      <S.GameSolvedTitle variant="h4">
        <S.HighlightContainer>The winner is:</S.HighlightContainer>
      </S.GameSolvedTitle>
      <Typography variant="h6">
        {(isPlayer1Winner as unknown as boolean) === false ? (
          <strong>Player2: {player2 as unknown as Address}</strong>
        ) : (isPlayer2Winner as unknown as boolean) === false ? (
          <strong>Player 1: {player1 as unknown as Address}</strong>
        ) : (
          <strong>Everyone winned. Game tied! 🪢</strong>
        )}
      </Typography>
    </S.GameSolvedContainer>
  ) : (
    <></>
  );
}

export default GameSessionPage;

Enter fullscreen mode Exit fullscreen mode

When the first player (the creator of the game) visits the game session page, he’ll see whether the second player submitted his move already or not yet, as well as how much time is left until the game is eligible for timeout.

When the second player visits the game session page, he’ll be able to submit his move & wait for the first player to reveal his commitment & solve the game.

Similarly, he can claim a timeout if the 1st player went unresponsive.

I’m not using useContractEvent for this page because it only watches the events in real-time, but I want to display the correct state even after the game is finished, if the user wants to visit the page later.

The final dApp from the first player’s view came out to this:

/ — Welcome page with all active game sessions displayed with useContractRead & useContractReads wagmi viem
The active game sessions page 👆

Metamask useSignMessage request result | Wagmi, React, Viem, Browser Subtle Crypto API

Metamask useContractWrite | Solidity Factory pattern

The prompts from my Metamask were not recorded on the video due to my video recorder’s security limitations. These looks like 👆👆👆

⌚ When the time runs out, the 1st player can claim the timeout and get his stake back before the 2nd player can join the game. The game session will get disactivated automatically. 👇

Image description

For the second player, the game session page allows him to select his move & submit it.

Rock paper scissors lizard spock Solidity & React game: Player2, submit your move!

useContractWrite: “play”() function

The last thing for the player 1 is to solve the game by revealing his commitment:

Both players submitted their moves of choice

Solve game — rock paper scissors lizard spock!

Wagmi, React & Typescript: Game solved successfully! Contract write (useContractWrite) completed.

The game is finished & the stake was distributed fairly.

If the user claims a timeout considering there really is no time left for the opponent to make his move, the result is:

block.timestamp best practices Timeout claimed Solidity claimTimeout() game smart contract | React Context API

Let me know if this tutorial was helpful! © Built with love and a pinch of posture health. 🙂🙆‍♀️

If you have any questions or ideas, make sure to ask those in the comments below. 🗨️

Top comments (0)