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);
}
}
}
}
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];
}
}
✅ 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>
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>
);
}
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]
});
👉 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: [...]
}
};
💎 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.
- 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;
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;
- 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.
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),
});
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:
🔐 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 ethers
’ solidityKeccak256
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],
});
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.` });
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;
⚠️ 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.
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;
onReplaced: (replacement) => {
console.log({ replacement });
if (replacement.reason === "cancelled") {
setErrorMessage?.(`Transaction ${transactionHash} was cancelled`);
return;
} else {
setTransactionHash(replacement.transactionReceipt.transactionHash);
}
},
👆 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];
};
// utils/validators.ts
import { isAddress } from "viem";
export const validateAddress = (address : string | undefined) => address && isAddress(address);
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.
- 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;
- 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;
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:
The active game sessions page 👆
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. 👇
For the second player, the game session page allows him to select his move & submit it.
The last thing for the player 1 is to solve the game by revealing his commitment:
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:
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)