Introduction
In this tutorial, we will explore how to create a decentralized application (DApp) on the Tezos blockchain using ReactJS. ReactJS, is a popular and flexible JavaScript library for building user interfaces.
In this tutorial we are going to build a decentralized TicTacToe based on this smart-contract that we’ve written in Pymich. This is a pretty simple smart-contract that allows user create games, join & leave games and play games. The rule is that each player pays 5 XTZ to play the game and the winner gets 10 XTZ and if it’s a draw then you will get your money back.
You can find a demo here.
By the end of this tutorial, you will have a basic understanding of how to create a DApp on the Tezos blockchain using ReactJS, and be equipped with the necessary skills to continue exploring possibilities of decentralized application development on the Tezos network. Let's get started !
If you get lost in the code during the tuto, you can find a full version of the App on githhub !
Requirements
Before we start, make sure that you check all the following requirements.
Install :
Have a basic knowledge of :
- ReactJS
- Javascript/Typescript
- How blockchain works
Init React App
Now that we’re ready to start, let’s initialize the app with create-react-app , run the following command where you want to store the app on your computer.
# Command
npx create-react-app tictactoe --template typescript
cd tictactoe
Folder Structure
Once you’ve created the app you should have the following folder structure.
├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
├── src
│ ├── App.css
│ ├── App.test.tsx
│ ├── App.tsx
│ ├── index.css
│ ├── index.tsx
│ ├── logo.svg
│ ├── react-app-env.d.ts
│ ├── reportWebVitals.ts
│ └── setupTests.ts
└── tsconfig.json
This structure is a default one and we’ll update it to make it more readable and organized. Let’s update the src file structure as followed.
src
├── app # All app related files such as routes, context providers, styles etc...
├── components # All the reusable components
├── context # All the context declarations
├── pages # All the pages of the app
└── utils # All utils features like api connection, helpers etc...
Once you’ve created the new folders, you will have to move some files as the following structure mentions.
src
├── app
│ ├── styles # Create a new folder for styles
│ │ └── App.css # Move App.css into this folder
│ └── App.tsx # Move App.tsx
├── components
├── context
├── pages
├── utils
├── App.test.tsx
├── index.css
├── index.tsx
├── logo.svg
├── react-app-env.d.ts
├── reportWebVitals.ts
└── setupTests.ts
After make sure that you update the imports of the files when you move them. If you’re on vscode it updates the import automatically but you still have to update some imports is the following files : index.tsx, app.test.tsx, App.tsx.
Now you can run you server with npm start and you should see the default react template running.
Now that we have all the structure set up let’s create a router for the app to handle routes rendering.
Router
Before beginning this step you will have to install the router library that we will use : react-router-dom
npm install react-router-dom
Then you can create a new file under src/app/ called Routes.tsx
// src/app/Routes.tsx
import React from "react";
import { Routes as AppRoutes, Route } from "react-router-dom";
const Routes = () => {
return (
{/* We will declare new routes here */}
);
};
export default Routes;
Here we are declaring the router with no routes inside. We’ll add routes later !
Now we need to link our router with the app. So let’s jump into the App.tsx file.
You can remove all its content and replace it with this.
// src/app/App.tsx
import React from 'react';
import { BrowserRouter as Router } from "react-router-dom";
import './App.css';
import Routes from './Routes';
function App() {
return (
{/* Add a browser router provider to host the routes */}
{/* Add the Routes component declared before */}
);
}
export default App;
Our app can now handle routes rendering.
Providers
Here we’ll create a file to store the app providers. Providers are components that provide a way to pass data down through the component tree without having to pass props manually at every level. By wrapping components in a provider, any nested child component can access the data passed down through the provider. This allows for a more efficient and convenient way to manage state and share data between components.
So create a new file under src/app called Providers.tsx.
// src/app/Providers.tsx
import React, { FC, ReactNode } from 'react'
// Declare a new type for props
type ProvidersProps = {
children: ReactNode
}
// Provide props type and add children
const Providers: FC = ({ children }) => {
// For now we do not have any provider
// so we'll just wrap children with a div
return (
{children}
)
}
export default Providers
The Providers component will have a children property that will contain all the children components.
We now need to link the Providers component to the app. Jump into the App.tsx file.
// src/app/App.tsx
import React from 'react';
import { BrowserRouter as Router } from "react-router-dom";
import './styles/App.css';
import Routes from './Routes';
import Providers from './Providers'; // import the providers component
function App() {
return (
{/* Replace the existing wrapper div with the Providers */}
);
}
export default App;
The app can now handle context management.
Frontend
In this part we’ll work on integrate the HTML structure and all the corresponding styles. Again you can find a demo of the screens here.
General styling
Before we start integrating screens let’s do some basic css setup.
You can remove all the content of the src/app/styles/App.css file and paste the below code.
/* src/app/styles/App.css */
/* Import font */
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap');
body {
margin: 0;
font-family: 'Roboto', sans-serif; /* apply font */
}
/* Other css setup */
html, body {
box-sizing: border-box;
height: 100%;
width: 100%;
}
#root {
height: 100%;
width: 100%;
}
*, *:before, *:after {
box-sizing: inherit;
}
Styling Library
We will handle the app styling with a famous react styling library called styled-components
npm install --save-dev @types/styled-components
Setup a theme
Now that we have installed the package, let’s create a theme for handling colors, spacing etc..
In order to proceed, we need to create two files. One for defining the default theme interface and the other for declaring the theme values.
// src/app/styles/styled.d.ts
import "styled-components";
// Define the default theme interface
declare module "styled-components" {
export interface DefaultTheme {
colors: {
primary: string;
secondary: string;
systemGrey: string;
systemYellow: string;
systemGreen: string;
};
}
}
// src/app/styles/theme.styled.ts
import { DefaultTheme } from "styled-components";
// Define here the default theme values
export const main: DefaultTheme = {
colors: {
primary: "rgba(128, 207, 224, 1)",
secondary: "rgba(217, 100, 99, 1)",
systemGrey: "#e0e0e0",
systemYellow: "#E8A029",
systemGreen: "#3CC27A"
},
};
We also need to wrap our app inside the theme provider in order the share the theme context to the whole application.
// src/app/Providers.tsx
import React, { FC, ReactNode } from 'react'
import { ThemeProvider } from 'styled-components' // import the ThemeProvider
import { main } from './styles/theme.styled' // import the default theme
type ProvidersProps = {
children: ReactNode
}
const Providers: FC = ({ children }) => {
return (
{/* Wrap up the app with the default theme */}
{children}
)
}
export default Providers
Create a new page
We will now create a new page to render the home view.
Sounder src/pages/ create a new folder called HomePage.
The first one called HomePage.tsx which will contain the home page component declaration.
// src/pages/HomePage/HomePage.tsx
import React from 'react'
import { HomePageWrapper } from './HomePage.styled'
const HomePage = () => {
return (
HomePage
)
}
export default HomePage
And the second one called HomePage.styled.ts which will contain all the style declarations for the home page.
// src/pages/HomePage/HomePage.styled.ts
import styled from "styled-components"
// Here we are declaring a new styled div
export const HomePageWrapper = styled.div`
// css will be declared here !
`;
This will be the structure that we will follow for all the pages and components of the App.
Let’s now link this new page with the router.
// src/app/Routes.tsx
import React from "react";
import { Routes as AppRoutes, Route } from "react-router-dom";
import HomePage from "../pages/HomePage/HomePage";
const Routes = () => {
return (
} /> {/* Add a new route here */}
);
};
export default Routes;
Create the App header
We will now create the first component of the app: the Header. In order to do that, you can create a new component folder under src/components.
Here are the files for the header component and its corresponding style.
// src/components/Header/Header.tsx
import React from 'react'
import {
AppTitle,
ConnectButton,
HeaderWrapper,
WalletBalanceLabel,
WalletInfo
} from './Header.styled'
const Header = () => {
return (
TicTacToe
Balance: 1.2 xtz
Connect Wallet
)
}
export default Header;
// src/components/Header/Header.styled.ts
import styled from "styled-components";
export const HeaderWrapper = styled.div`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 70px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
`;
export const AppTitle = styled.h1`
margin: auto 0;
`;
export const WalletInfo = styled.div`
display: flex;
align-items: center;
gap: 1rem;
`;
export const WalletBalanceLabel = styled.div`
font-size: 14px;
font-weight: 600;
`;
export const ConnectButton = styled.button`
padding: 0.8rem 2rem;
border: none;
/* Here we are getting the color from the theme */
background-color: ${(props) => props.theme.colors.primary};
border-radius: 10px;
cursor: pointer;
font-family: "Roboto", sans-serif;
font-weight: 700;
color: white;
font-size: 18px;
`;
In order to render the header component, let’s add it in the App.tsx file.
// src/app/App.tsx
// imports...
function App() {
return (
{/* Add the Header here */}
);
}
export default App;
Home Page
We’ll now fill the home page that we created before.
// src/pages/HomePage/HomePage.tsx
import React from 'react'
import { main } from '../../app/styles/theme.styled'
import Games from './Games/Games'
import {
ButtonsWrapper,
HomePageWrapper,
ActionButton
} from './HomePage.styled'
import Statistics from './Statistics/Statistics'
const HomePage = () => {
return (
{/* Sub component here */}
My Games
Available Games
New Game
{/* Sub component here */}
)
}
export default HomePage
// src/pages/HomePage/HomePage.styled.ts
import styled from "styled-components"
export const HomePageWrapper = styled.div`
position: relative;
top: 70px;
left: 0;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
`;
export const ButtonsWrapper = styled.div`
display: flex;
align-items: center;
gap: 1rem;
`;
export const ActionButton = styled.button<{
backgroundColor?: string;
}>`
margin: 2rem 0 1rem 0;
padding: 0.5rem 1rem;
cursor: pointer;
border: none;
background-color: ${(props) => props.backgroundColor || props.theme.colors.systemGrey};
font-size: 20px;
font-family: inherit;
border-radius: 10px;
color: ${(props) => props.backgroundColor ? "white" : "black"};
`;
Declare sub components
We can see that there are several subcomponents used inside the page component that we just created. Those subcomponents are categorized as unsharable, this means that they are supposed to be used once. That’s why they will be declared in a subfolder inside the page folder.
// src/pages/HomePage/HomePage/Statistics/Statistics.tsx
import React from 'react'
import { main } from '../../../app/styles/theme.styled'
import {
StatFigure,
StatisticsWrapper,
StatTitle,
StatWrapper
} from './Statistics.styled'
const Statistics = () => {
return (
Games
200
Won
100
Draw
100
Lost
100
)
}
export default Statistics
// src/pages/HomePage/HomePage/Statistics/Statistics.styled.ts
import styled from "styled-components"
export const StatisticsWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
export const StatWrapper = styled.div<{
borderRight?: boolean;
}>`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.7rem;
min-width: 100px;
padding: 0.5rem 0;
// Here we are using the borderRight prop to conditionally add a border to the right of the StatWrapper
border-right: ${(props) => props.borderRight && ("1px solid " + props.theme.colors.systemGrey)};
`;
export const StatTitle = styled.h3`
margin: 0;
`;
export const StatFigure = styled.div<{
color?: string;
}>`
color: ${(props) => props.color || "black"};
font-size: 25px;
font-weight: bold;
`;
We also have the Games subcomponent to declare
// src/pages/HomePage/HomePage/Games/Games.tsx
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { GameCard } from '../../../components/GameCard/GameCard';
import { GamesWrapper } from './Games.styled'
const Games = () => {
// use the navigation hook to make Game Cards clickable
const navigate = useNavigate();
return (
{/* Here we are creating two placeholders for the game items*/}
navigate("/game/1")} />
navigate("/game/2")} />
)
}
export default Games
// src/pages/HomePage/HomePage/Games/Games.styled.ts
import styled from "styled-components";
export const GamesWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 30%;
margin-top: 2rem;
`;
And finally we will create another component that is the game card. This one will be shared so let’s declare it inside the components folder.
// src/components/GameCard/GameCard.tsx
import React, { FC } from 'react'
import { main } from '../../app/styles/theme.styled';
import {
GameStatusLabel,
GameTitle,
GameWrapper
} from './GameCard.styled';
type GameCardProps = {
onClick: () => void;
}
// extend the FC (Function Component) class with the props generic type
export const GameCard: FC = ({ onClick }) => {
return (
Game ID: 12
Opponent: 0x123...
LOST
)
}
Game Page
Once we created the Home page we will now create the second page that our app will render : the GamePage. Let’s proceed.
// src/pages/GamePage/GamePage.tsx
import React from 'react'
import { useParams } from 'react-router-dom'
import { main } from '../../app/styles/theme.styled'
import {
GamePageWrapper,
PlayerLabel,
PlayersWrapper,
StatusLabel,
VSLabel
} from './GamePage.styled'
import Grid from './Grid/Grid'
// Declaring type for Location Params
type LocationParams = {
id: string
}
const GamePage = () => {
// retrieve id from location params
const { id } = useParams();
return (
tz1....
VS
tz1....
YOU WON
)
}
export default GamePage
// src/pages/GamePage/GamePage.styled.ts
import styled from "styled-components"
export const GamePageWrapper = styled.div`
position: relative;
top: 70px;
left: 0;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
margin: 2rem 0;
`;
export const PlayersWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
`;
export const PlayerLabel = styled.div<{
align?: string;
}>`
font-size: 26px;
text-align: ${(props) => props.align || "left"};
font-weight: 600;
`;
export const VSLabel = styled.div`
font-size: 26px;
font-weight: 700;
`;
export const StatusLabel = styled.div<{
color?: string;
}>`
font-size: 36px;
font-weight: bold;
color: ${(props) => props.color || "black"};
`;
Grid Component
We need also to declare the TicTacToe Grid as a subcomponent.
// src/pages/GamePage/Grid/Grid.tsx
import React, { FC } from 'react'
import { Cell, GridWrapper, MovesLabel, Row } from './Grid.styled'
type GridProps = {
game_id: number;
}
const Grid: FC = ({ game_id }) => {
return (
Moves: 2
{[...Array(3)].map((_, j: number) => {
return (
{[...Array(3)].map((_, i: number) => (
|
))}
)
})}
)
}
export default Grid
// src/pages/GamePage/Grid/Grid.tsx
import React, { FC } from 'react'
import { Cell, GridWrapper, MovesLabel, Row } from './Grid.styled'
type GridProps = {
game_id: number;
}
const Grid: FC = ({ game_id }) => {
return (
Moves: 2
{[...Array(3)].map((_, j: number) => {
return (
{[...Array(3)].map((_, i: number) => (
|
))}
)
})}
)
}
export default Grid
Link the page to the App
There is one little thing that left to do, link the page to the app.
// src/app/Routes.tsx
// imports...
const Routes = () => {
return (
} />
{/* Link the game page with a location param called 'id' */}
} />
);
};
Wallet Connection
In this part we’ll focus on handling the wallet connection.
In order to do this we will use beacon wallet through the taquito library. So we’ll need to install the two following libraries but before we need to downgrade react-scripts version to avoid some breaking changes due to webpack 5 included in the react-scripts v5. Run the three following commands.
npm install react-scripts@4.0.3
npm install @taquito/taquito
npm install @taquito/beacon-wallet
Create a new context
The first step to proceed is to create a context for getting and storing wallet informations.
// src/context/Wallet.context.tsx
import React, { createContext, ReactNode } from "react";
import useWallet from "../utils/hooks/wallet/useWallet";
type WalletContextProps = {
// Here we'll add all the property that the context retrieves
};
type WalletProviderProps = {
children: ReactNode;
};
// Here we are creating our context
export const WalletContext = createContext({} as WalletContextProps);
const WalletProvider = ({ children }: WalletProviderProps) => {
// Here we'll declare all the of the context
return (
{children}
);
};
// We are exporting the provider to again wrap up the app with this provider
export default WalletProvider;
Let’s wrap up the app with the new context.
// src/app/Providers.tsx
// imports...
const Providers: FC = ({ children }) => {
return (
{children} {/* Nest the wallet provider inside the Theme Provider */}
)
}
Create an env file
One of the best practices of creating an app with react is to create an .envfile. So let’s do this and add the 3 following variables. The first two are quite linked because they are defining the network that the app will use to run (Ghostnet here). If those 2 variables are unconsistent this will result as an error. And the last one is defining the contract address that we’ll use to handle games.
REACT_APP_RPC_ADDRESS=https://rpc.ghostnet.teztnets.xyz/
REACT_APP_NETWORK=ghostnet
REACT_APP_CONTRACT_ADDRESS=KT1QjC7m4UDHcWihiQWcChX2UCN5FUXNenZw
In a more scalable app we would rather create one file per environment, for example one for the ghostnet (dev) and the 2 others for the mainnet (staging & production).
Create a taquito wallet handler file
In this file we will declare all the functions that will help us to connect, disconnect or getting the user balance etc…
// src/utils/taquito/main.taquito.ts
import { BeaconWallet } from "@taquito/beacon-wallet";
import { TezosToolkit } from "@taquito/taquito";
// create a TezosToolkit Object base on the RPC URL
export const tezos = new TezosToolkit(
process.env.REACT_APP_RPC_ADDRESS as string
);
// configure options for the Beacon wallet object and the request permission
const options = {
name: "TicTacToe",
preferredNetwork: process.env.REACT_APP_NETWORK as string,
};
const network = {
type: process.env.REACT_APP_NETWORK as string,
name: "TicTacToe",
rpcUrl: process.env.REACT_APP_RPC_ADDRESS as string,
};
// @ts-ignore
export const wallet = new BeaconWallet(options);
/**
* Request user permissions to connect to wallet
* @returns BeaconWallet object
*/
export const setProvider = async () => {
const activeAccount = await wallet.client.getActiveAccount();
// checking if there are any connected wallet
if (activeAccount) {
tezos.setProvider({ wallet });
return wallet;
}
// requesting user permissions
// @ts-ignore
await wallet.requestPermissions({ network });
tezos.setProvider({ wallet });
return wallet;
};
/**
* Check if user's wallet is connected
* @returns boolean
*/
export const isConnected = async () => {
const activeAccount = await wallet.client.getActiveAccount();
if (activeAccount) {
tezos.setProvider({ wallet });
return true;
}
return false;
};
/**
* This will disconnect user's wallet
* and clear all active accounts
* @returns void
*/
export const disconnectWallet = async () => {
try {
await wallet.clearActiveAccount();
tezos.setWalletProvider(wallet);
return true;
} catch (error) {
throw new Error("Error : " + error);
}
};
/**
* Get user XTZ Balance from wallet
* @param from address of the user to get balance from
* @returns Promise
*/
export const getBalance = (from: string) => {
try {
return tezos.tz.getBalance(from);
} catch (error) {
throw new Error("Error : " + error);
}
};
Create a wallet connection hook
Now that we have created our taquito helpers, we will create a hook to integrate those helpers with the context.
// src/utils/hooks/useWallet.ts
import { useState, useEffect } from "react";
import {
disconnectWallet,
getBalance,
isConnected,
setProvider,
} from "../../utils/taquito/main.taquito";
const useWallet = () => {
// Declare the state variables
const [currentUserBalance, setCurrentUserBalance] = useState();
const [currentWalletAddress, setCurrentWalletAddress] = useState();
const [isWalletConnected, setIsWalletConnected] = useState(false);
const initWallet = async () => {
try {
// requesting permission
const wallet = await setProvider();
// getting user wallet address
const userAddress = await wallet.getPKH();
// set state variables
setIsWalletConnected(true);
setCurrentWalletAddress(userAddress);
// getting user balance
const balance = getBalance(userAddress);
balance.then((res) => {
setCurrentUserBalance(res.toNumber() * 10 ** -6);
});
} catch (error) {
throw new Error("Error : " + error);
}
};
const disconnect = () => {
disconnectWallet();
setIsWalletConnected(false);
setCurrentWalletAddress(undefined);
setCurrentUserBalance(undefined);
}
useEffect(() => {
// checking if the connected user has been cached
// if it's the case, connect him by default
isConnected().then((res: boolean) => {
res && initWallet();
});
}, []);
// return hook state + functions
return {
currentUserBalance,
currentWalletAddress,
disconnectWallet,
initWallet,
isWalletConnected,
};
};
export default useWallet;
Then we need to link the hook with the context.
// src/context/wallet.context.tsx
import React, { createContext, ReactNode } from "react";
import useWallet from "../utils/hooks/useWallet";
type WalletContextProps = {
// Add the variables to the context props
currentUserBalance: number | undefined;
currentWalletAddress: string | undefined;
disconnect: () => void;
initWallet: () => void;
isWalletConnected: boolean;
};
type WalletProviderProps = {
children: ReactNode;
};
export const WalletContext = createContext({} as WalletContextProps);
const WalletProvider = ({ children }: WalletProviderProps) => {
// Retrieve variables from the useWallet hook
const {
currentUserBalance,
currentWalletAddress,
disconnect,
isWalletConnected,
initWallet,
} = useWallet();
return (
{children}
);
};
export default WalletProvider;
Link the frontend with the wallet connection
Now that we have all the wallet data that we want we are going to display them in the frontend.
First of all we’ll create a helper to format a wallet address into a shorter version.
// src/utils/helpers/Wallet.helpers.ts
export const getFormattedWalletAddress = (address: string) => {
return `${address.slice(0, 6)}...${address.slice(-5)}`;
};
Then jump into the header component where connect wallet buttons are declared.
// src/components/Header/Header.tsx
import React, { useContext } from 'react'
import { WalletContext } from '../../context/Wallet.context'
import { getFormattedWalletAddress } from '../../utils/helpers/Wallet.helpers';
import {
AppTitle,
ConnectButton,
HeaderWrapper,
WalletBalanceLabel,
WalletInfo
} from './Header.styled'
const Header = () => {
// get data from the Wallet context
const {
currentUserBalance,
currentWalletAddress,
disconnect,
initWallet,
isWalletConnected,
} = useContext(WalletContext);
return (
TicTacToe
{/* Display balance only if user is connected */}
{isWalletConnected && (
{`Balance: ${currentUserBalance?.toFixed(2) || "--"} xtz`}
)}
{/* Change button display depending on the wallet connection status */}
{isWalletConnected ?
getFormattedWalletAddress(currentWalletAddress || "") :
"Connect Wallet"
}
)
}
export default Header;
We also need to change the display to connect button.
// src/components/Header/Header.styled.ts
// styles....
// Change display depending on connection
export const ConnectButton = styled.button<{
isConnected: boolean;
}>`
padding: 0.8rem 2rem;
border: none;
background-color: ${(props) => props.isConnected ? props.theme.colors.primary : props.theme.colors.secondary};
border-radius: 10px;
cursor: pointer;
font-family: "Roboto", sans-serif;
font-weight: 700;
color: white;
font-size: 18px;
`;
Transactions
The next step is to add the interactions with smart-contract. Our contract has 5 entrypoints but we’ll use only 3 of them for this app. build_game, join_game and play.
Let’s create a file for declaring the required transactions.
// src/utils/taquito/transactions.taquito.ts
import { tezos } from "./main.taquito";
const CONTRACT_ADDRESS = process.env.REACT_APP_CONTRACT_ADDRESS as string;
/**
* Calls the entrypoint build_board from the smart contract
* @returns Promise
*/
export const build_board = async () => {
try {
// Get the contract abstraction
const contract = await tezos.wallet.at(CONTRACT_ADDRESS);
// Call the method build_board from the abstraction
return contract.methods
.build_board()
// The entrypoints requires us to send an amount of 5 XTZ
.send({ amount: 5, mutez: false })
.then((res: any) => res.confirmation(1));
} catch (error) {
throw error;
}
}
/**
* Calls the entrypoint join_game from the smart contract
* @param game_id number
* @returns Promise
*/
export const join_game = async (game_id: number) => {
try {
const contract = await tezos.wallet.at(CONTRACT_ADDRESS);
return contract.methods
.join_game(game_id)
.send({ amount: 5, mutez: false })
.then((res: any) => res.confirmation(1));
} catch (error) {
throw error;
}
}
/**
* Calls the entrypoint play from the smart contract
* @param x number - x coordinate
* @param y number - y coordinate
* @returns Promise
*/
export const play = async (x: number, y: number, game_id: number) => {
try {
const contract = await tezos.wallet.at(CONTRACT_ADDRESS);
return contract.methods
.play(game_id, x, y)
// Here no need to send an amount of XTZ
// because the entrypoint play doesn't require it
.send()
.then((res: any) => res.confirmation(1));
} catch (error) {
throw error;
}
}
Link the entrypoints to the frontend
First we’ll link the entry points build_game and play to the frontend. We’ll handle the third one later.
First download the following library to handle transaction toasts. This library will help us to render the transaction status to the user.
npm install react-toastify@8.2.0
But in order to do that, we have to add a toast container with the following props and also we need to add the toastify css file import at the root of the App.
// src/app/App.tsx
import React from 'react';
import { BrowserRouter as Router } from "react-router-dom";
import './styles/App.css';
import Routes from './Routes';
import Providers from './Providers';
import Header from '../components/Header/Header';
import { ToastContainer } from 'react-toastify';
// add the toastify css here
import "react-toastify/dist/ReactToastify.css";
function App() {
return (
{/* Add the toast container here */}
);
}
export default App;
Build Board entrypoint
Jump into the HomePage component, where the new game button is declared.
// src/pages/HomePage/HomePage.tsx
import React from 'react'
import { toast } from 'react-toastify'
import { main } from '../../app/styles/theme.styled'
import { build_board } from '../../utils/taquito/transactions.taquito'
import Games from './Games/Games'
import {
ButtonsWrapper,
HomePageWrapper,
ActionButton
} from './HomePage.styled'
import Statistics from './Statistics/Statistics'
const HomePage = () => {
return (
My Games
Available Games
// Create a toast that follows build_board promise state
toast.promise(
build_board,
{
// add promise state labels
pending: 'Creating new game...',
success: 'Game created!',
error: 'Oups! Error creating game'
}
)
}
>
New Game
)
}
export default HomePage
Play entrypoint
The play entrypoint is used in the Grid component, so let’s update it.
// src/pages/GamePage/Grid/Grid.tsx
import React, { FC } from 'react'
import { toast } from 'react-toastify';
import { play } from '../../../utils/taquito/transactions.taquito';
import { Cell, GridWrapper, MovesLabel, Row } from './Grid.styled'
type GridProps = {
game_id: number;
}
const Grid: FC = ({ game_id }) => {
console.log(game_id);
return (
Moves: 2
{[...Array(3)].map((_, j: number) => {
return (
{[...Array(3)].map((_, i: number) => (
(
toast.promise(
() => play(i + 1, j + 1, game_id),
{
pending: 'Playing...',
success: 'Played!',
error: 'Oups! Error playing'
}
)
)}
/>
))}
|
)
})}
)
}
export default Grid
Retrieve games data
Retrieving data from the blockchain is necessary to make the app working. But the problem with this is that it needs to involve a third part into to the loop. They are several ways to do that like creating an indexer, getting data from the storage with an API or with taquito. In this tutorial we are going to use an external API to do that, this is the easier and quicker way to proceed. We’ll use the API from TZKT.
Create Data types
As we are using typescript we definitely want to have typed data and avoid using the anytype as much as possible. That’s why we are creating types for the data that we will recieve from the API.
// src/utils/types/game.types.ts
export type BoardRow = {
row_id: string;
cells: String[];
}
export type Game = {
board_id: number;
player1: string;
player2: string;
moves_number: number;
winner: string;
draw: boolean;
started: boolean;
finished: boolean;
board: BoardRow[];
next_player: number;
}
Create a Game context
In order to retrieve those datas we’ll need to create a new context for handling stuff related to games.
// src/context/Game.context.tsx
import React, { createContext, ReactNode } from "react";
type GameContextProps = {
// Empty props for now
};
type GameProviderProps = {
children: ReactNode;
};
export const GameContext = createContext({} as GameContextProps);
const GameProvider = ({ children }: GameProviderProps) => {
return (
{children}
);
};
export default GameProvider;
Link it in the providers.
// src/app/Providers.tsx
// imports...
const Providers: FC = ({ children }) => {
return (
{children}
)
}
Get all games from the contract storage
We are going to use axios to get storage data from TZKT API. So let’s install it.
Create a new file to create an helpers to get all the games from the storage.
// src/utils/taquito/games.taquito.ts
import axios from "axios";
export const getAllGames = async () => {
try {
// retrieve keys of the game bigmap (id 245527)
const url = "https://api.ghostnet.tzkt.io/v1/bigmaps/245527/keys/";
const response = await axios.get(url);
return response.data;
} catch (error) {
throw error;
}
}
Now jump into the GameContext.
// src/context/Game.context.tsx
import React, { createContext, ReactNode, useEffect, useState } from "react";
import { getAllGames } from "../utils/taquito/games.taquito";
import { Game } from "../utils/types/game.types";
type GameContextProps = {
games: Game[];
};
type GameProviderProps = {
children: ReactNode;
};
export const GameContext = createContext({} as GameContextProps);
const GameProvider = ({ children }: GameProviderProps) => {
const [games, setGames] = useState([]);
// get games on load of the page and store them into the context
useEffect(() => {
getAllGames()
.then((res) => setGames(res.map((item: any) => item.value as Game)));
}, []);
return (
{children}
);
};
export default GameProvider;
Add games statistics
We are now going to render games statistics in the HomePage.
Create a games helpers file to export data calculation outside of the component.
// src/utils/helpers/game.helpers.ts
import { main } from "../../app/styles/theme.styled";
import { Game } from "../types/game.types";
export const getGameById = (games: Game[], id: number) => {
return games.find((game: Game) => game.board_id.toString() === id.toString());
};
export const getMyGames = (games: Game[], wallet_address: string) => {
return games.filter((game: Game) => (
game.player1 === wallet_address || game.player2 === wallet_address
));
}
export const getGamesWon = (games: Game[], wallet_address: string) => {
const myGames = getMyGames(games, wallet_address);
return myGames.filter((game: Game) => (
game.finished && game.winner === wallet_address && !game.draw
)).length;
}
export const getGamesDraw = (games: Game[], wallet_address: string) => {
const myGames = getMyGames(games, wallet_address);
return myGames.filter((game: Game) => (
game.finished && game.draw
)).length;
}
export const getGamesLost = (games: Game[], wallet_address: string) => {
const myGames = getMyGames(games, wallet_address);
return myGames.filter((game: Game) => (
game.finished && game.winner !== wallet_address
)).length;
};
export const getAvailableGames = (games: Game[], wallet_address: string) => {
return games.filter((game: Game) => (
!game.started && game.player1 === game.player2 && game.player1 !== wallet_address
));
}
export const getMyOpponent = (game: Game, wallet_address: string) => {
return game.player1 === wallet_address ? game.player2 : game.player1;
}
export const getGameStatus = (game: Game, wallet_address: string) => {
if (game.finished) {
if (game.draw) {
return "draw";
} else if (game.winner === wallet_address) {
return "won";
} else {
return "lost"
}
} else if (game.started) {
return "started";
} else {
return "waiting";
}
}
export const getGameStatusColor = (game: Game, wallet_address: string) => {
const status = getGameStatus(game, wallet_address);
switch (status) {
case "won":
return main.colors.primary;
case "lost":
return main.colors.secondary;
case "waiting":
return main.colors.systemYellow;
case "started":
return main.colors.systemGreen;
default:
return "black";
}
}
Then jump into the Statistics component to render all those stats.
// src/pages/HomePage/Statistics/Statistics.tsx
import React, { useContext, useMemo } from 'react'
import { main } from '../../../app/styles/theme.styled'
import { GameContext } from '../../../context/Game.context'
import { WalletContext } from '../../../context/Wallet.context'
import {
getGamesDraw,
getGamesLost,
getGamesWon,
getMyGames
} from '../../../utils/helpers/game.helpers'
import {
StatFigure,
StatisticsWrapper,
StatTitle,
StatWrapper
} from './Statistics.styled'
const Statistics = () => {
const { currentWalletAddress } = useContext(WalletContext);
const { games } = useContext(GameContext);
// retrieve calculation results
const gamesPlayed = useMemo(() => getMyGames(games, currentWalletAddress || "").length, [games, currentWalletAddress]);
const gamesWon = useMemo(() => getGamesWon(games, currentWalletAddress || ""), [games, currentWalletAddress]);
const gamesDraw = useMemo(() => getGamesDraw(games, currentWalletAddress || ""), [games, currentWalletAddress]);
const gamesLost = useMemo(() => getGamesLost(games, currentWalletAddress || ""), [games, currentWalletAddress]);
return (
Games
{/* Retrieve data only if the user is connected */}
{currentWalletAddress ? gamesPlayed : "--"}
Won
{currentWalletAddress ? gamesWon : "--"}
Draw
{currentWalletAddress ? gamesDraw : "--"}
Lost
{currentWalletAddress ? gamesLost : "--"}
)
}
export default Statistics
Home Page update
Now we need to update the home page to dynamise the display when the user chose to see his own game or wants to join a new game.
// src/pages/HomePage/HomePage.tsx
// imports ...
const HomePage = () => {
const [gamesView, setGamesView] = useState<1 | 2>(1);
return (
setGamesView(1)}
>
My Games
setGamesView(2)}>Available Games
toast.promise(
build_board,
{
pending: 'Creating new game...',
success: 'Game created!',
error: 'Oups! Error creating game'
}
)
}
>
New Game
)
}
export default HomePage
We also need to update the display of the games list nested in the home page.
// src/pages/HomePage/Games/Games.tsx
// imports ...
type GamesProps = {
gamesView: 1 | 2;
}
const Games: FC = ({ gamesView }) => {
const [gamesList, setGamesList] = useState([]);
const { currentWalletAddress } = useContext(WalletContext);
const { games } = useContext(GameContext);
const myGames = useMemo(
() => getMyGames(games, currentWalletAddress || ""),
[games, currentWalletAddress]
);
const availableGames = useMemo(
() => getAvailableGames(games, currentWalletAddress || ""),
[games, currentWalletAddress]
);
const navigate = useNavigate();
useEffect(() => {
setGamesList(gamesView === 1 ? myGames : availableGames)
}, [gamesView, myGames, availableGames]);
return (
{gamesList.map((game: Game, key: number) => (
navigate(`game/${game.board_id}`)} game={game} />
))}
)
}
export default Games
And finally we need to update the GameCard component behavior.
// src/components/GameCard/GameCard.tsx
import React, { FC, useContext, useMemo } from 'react'
import { WalletContext } from '../../context/Wallet.context';
import { getGameStatus, getGameStatusColor, getMyOpponent } from '../../utils/helpers/game.helpers';
import { getFormattedWalletAddress } from '../../utils/helpers/Wallet.helpers';
import { Game } from '../../utils/types/game.types';
import {
GameStatusLabel,
GameTitle,
GameWrapper,
JoinGameButton
} from './GameCard.styled';
import { toast } from "react-toastify";
import { join_game } from '../../utils/taquito/transactions.taquito';
type GameCardProps = {
onClick: () => void;
game: Game;
}
export const GameCard: FC = ({ onClick, game }) => {
const { currentWalletAddress } = useContext(WalletContext);
const myOpponent = useMemo(() => (
getMyOpponent(game, currentWalletAddress || "")
),
[game, currentWalletAddress]
);
const status = useMemo(
() => getGameStatus(game, currentWalletAddress || ""),
[game, currentWalletAddress]
);
const statusColor = useMemo(
() => getGameStatusColor(game, currentWalletAddress || ""),
[game, currentWalletAddress]
);
return (
Game ID: {game.board_id}
Opponent: {" "}
{myOpponent === currentWalletAddress ? "--" : getFormattedWalletAddress(myOpponent)}
{status === "waiting" && game.player1 !== currentWalletAddress ? (
toast.promise(
() => join_game(game.board_id),
{
pending: "Joining the game...",
success: "Game joined!",
error: "Failed to join the game",
}
)
}
>
Join Game
) : (
{status}
)
}
)
}
// src/components/GameCard/GameCard.styled.ts
import styled from "styled-components"
export const GameWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background-color: ${(props) => props.theme.colors.systemGrey};
padding: 1rem;
border-radius: 10px;
cursor: pointer;
`;
export const GameTitle = styled.div`
font-size: 20px;
font-weight: bold;
margin-bottom: 1rem;
`;
export const GameStatusLabel = styled.div<{
color?: string;
}>`
font-size: 28px;
text-transform: uppercase;
font-weight: bold;
color: ${(props) => props.color || "black"};
`;
export const JoinGameButton = styled.button`
padding: 0.5rem 1rem;
border: none;
background: ${(props) => props.theme.colors.systemYellow};
font-family: inherit;
font-weight: 600;
font-size: 20px;
border-radius: 10px;
cursor: pointer;
color: white;
&:hover {
opacity: 0.8;
}
`;
Game Page update
The final part of the update is about updating the GamePage with a dynamic version where the user can see all the game details.
// src/pages/GamePage/Grid/Grid.styled.ts
import React, { useContext, useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { GameContext } from '../../context/Game.context'
import { WalletContext } from '../../context/Wallet.context'
import { getGameById, getGameStatus, getGameStatusColor } from '../../utils/helpers/game.helpers'
import { getFormattedWalletAddress } from '../../utils/helpers/Wallet.helpers'
import { Game } from '../../utils/types/game.types'
import {
GamePageWrapper,
PlayerLabel,
PlayersWrapper,
StatusLabel,
VSLabel
} from './GamePage.styled'
import Grid from './Grid/Grid'
type LocationParams = {
id: string
}
const GamePage = () => {
const { id } = useParams();
const [game, setGame] = useState();
const { games } = useContext(GameContext);
const { currentWalletAddress } = useContext(WalletContext);
useEffect(() => {
const game = getGameById(games, Number(id));
game && setGame(game);
}, [id, games]);
const status = useMemo(
() => game && getGameStatus(game, currentWalletAddress || ""),
[game, currentWalletAddress]
);
const statusColor = useMemo(
() => game && getGameStatusColor(game, currentWalletAddress || ""),
[game, currentWalletAddress]
)
return (
{game ? getFormattedWalletAddress(game.player1) : "--"}
VS
{game ? getFormattedWalletAddress(game.player2) : "--"}
{game?.finished && (
{
status === "won" ?
"You won" :
status === "lost" ?
"You lost" :
"Draw"
}
)}
)
}
export default GamePage
Update also the Grid display.
// src/pages/GamePage/Grid/Grid.tsx
import React, { FC, useContext } from 'react'
import { toast } from 'react-toastify';
import { WalletContext } from '../../../context/Wallet.context';
import { getBoardCellStatus } from '../../../utils/helpers/game.helpers';
import { play } from '../../../utils/taquito/transactions.taquito';
import { Game } from '../../../utils/types/game.types';
import { Cell, GridWrapper, MovesLabel, Row } from './Grid.styled'
type GridProps = {
game?: Game;
}
const Grid: FC = ({ game }) => {
const { currentWalletAddress } = useContext(WalletContext);
return (
Moves: {game?.moves_number || "--"}
{[...Array(3)].map((_, j: number) => {
return (
{[...Array(3)].map((_, i: number) => {
const cellStatus = getBoardCellStatus(i + 1, j + 1, game);
return (
toast.promise(
() => play(i + 1, j + 1, game?.board_id || 0),
{
pending: 'Playing...',
success: 'Played!',
error: 'Oups! Error playing'
}
)
)}
/>
})}
|
)
})}
)
}
export default Grid
Conclusion
In conclusion, building a decentralized application on the Tezos blockchain can be an exciting and rewarding experience. By leveraging the unique features of the blockchain, such as its on-chain governance and formal verification capabilities, developers can create secure and reliable applications that offer a high degree of transparency and user empowerment. From creating a smart contract to building a front-end interface with React or other web development tools, there are many steps involved in developing a successful DApp on Tezos. However, by following the best practices outlined in this tutorial and continuing to explore the possibilities of the Tezos ecosystem, developers can unlock a wealth of new opportunities and possibilities in the world of decentralized finance and applications.
Of course this application can be improved, here are several options that you can explore to develop your skill with this app.
- Make a real-time app with live data (user does not have to refresh the page to see new data), try to use react-query library to fetch data at a determined interval.
- Create an option for leaving the game. Interact with the leave_game entrypoint.
- Improve the UI/UX to make it more modern and user friendly
It’s now up to you !
As I said earlier, the whole project is hosted on github. There might be some little bugs or improvements to make so feel free to open issues (and/or make PR) on the repo. Also if you have any question you can open issues and we’ll have a talk about your problem !