Aviator Web3 Game with WebGL
Time to complete: 40 minutes
In this guide we will go through the process of integrating WebGL in a game, leveraging tools from the Sequence Stack to earn achievements and use custom ERC1155's to play in-game.
The tools will enable you to perform:
- Project Setup With Webpack: Enable a project structure with WebGL to be compiled by Webpack
- Integrate Sequence Kit: Allow all EOAs and Sequence Wallet to authenticate the user
- Deploy a Collectibles Contract: Create your own collectible contract
- Deploy a Remote Minter and Mint In-game Tokens: Perform gasless relayed transactions with Cloudflare workers
- Leverage Items In-game: Integrate collectibles in the game using the Sequence Indexer
- Burn In-game Achievement tokens: Burn game achievements with wagmi
- (Optional) Integrate Embedded Wallet Into Sequence Kit: Enable smooth UX without the use of signer signed messages
1. Project setup with webpack
Clone Repo
We'll first start by cloning down a template project, which has a few WebGL based components created using three
, all compiled using webpack
Template WebGL JS Sequence Kit Starter
Clone the above repo down, cd
into the repo with cd template-webgl-js-sequence-kit-starter
Update .env
Create a .env
file (using the .env.example
) with the environment variables
PROJECT_ACCESS_KEY=
WALLET_CONNECT_ID=
And, run the following commands to run the app
# or your choice of package manager
pnpm install
pnpm run dev
Great, you should see a plane flying over water
2. Integrate Sequence Kit
Now that we have a project structure set up, we can integrate Sequence Kit
Setup App.jsx
Component
Create a folder within the src
folder called react
and create 2 files: App.jsx
and Login.jsx
In App.jsx
include the following code
import React from "react";
import { useOpenConnectModal } from "@0xsequence/kit";
import { useDisconnect, useAccount } from "wagmi";
import Login from "./Login.jsx";
import { KitProvider } from "@0xsequence/kit";
import { getDefaultConnectors } from "@0xsequence/kit-connectors";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createConfig, http, WagmiConfig } from "wagmi";
import { arbitrumSepolia, Chain } from "wagmi/chains";
const queryClient = new QueryClient();
function App(props) {
const chains = [arbitrumSepolia];
const projectAccessKey = process.env.PROJECT_ACCESS_KEY;
const connectors = getDefaultConnectors( "universal", {
walletConnectProjectId: process.env.WALLET_CONNECT_ID,
defaultChainId: 421614,
appName: "demo app",
projectAccessKey,
});
const transports = {};
chains.forEach((chain) => {
transports[chain.id] = http();
});
const config = createConfig({
transports,
connectors,
chains,
});
return (
<WagmiConfig config={config}>
<QueryClientProvider client={queryClient}>
<KitProvider config={{ defaultTheme: "dark" }}>
<Login scene={props.scene}/>
</KitProvider>
</QueryClientProvider>
</WagmiConfig>
);
}
export default App;
Then, in the Login.jsx
file add the following code in order to create a button at the top of the screen to login to the application
import React, {useEffect} from "react";
import { useOpenConnectModal } from "@0xsequence/kit";
import { useDisconnect, useAccount, useWalletClient } from "wagmi";
function Login(props) {
const { setOpenConnectModal } = useOpenConnectModal();
const { data: walletClient } = useWalletClient();
const { isConnected } = useAccount();
const { disconnect } = useDisconnect();
useEffect(() => {
if(isConnected){
props.scene.login()
} else {
props.scene.logout()
}
}, [isConnected])
const sendBurnToken = async () => {
// empty for now
}
useEffect(() => {
if (isConnected && walletClient) {
props.scene.sequenceController.init(
walletClient,
sendBurnToken
);
}
}, [isConnected, walletClient]);
return (
<>
<div style={{ textAlign: "center" }}>
<br />
{isConnected && (
<div
onClick={() => disconnect()}
style={{
cursor: "pointer",
position: "fixed",
top: "30px",
right: "30px",
zIndex: '1'
}}
>
sign out
</div>
)}
</div>
</>
);
}
export default Login;
Render Component In Javascript index.js
Finally, add in the index.js
import the App.jsx
component, and render it to be appended to the root
id element in index.html
import * as ReactDOM from 'react-dom/client';
import App from './react/App.jsx'
...
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<App scene={mainScene}/>
);
Create a Click Handler To Call The Login Modal
Add the following code to Login.jsx
component
window.setOpenConnectModal = () => {
setOpenConnectModal(true);
};
And the following click handler code to index.js
function handleMouseUp(event) {
window.setOpenConnectModal()
}
document
.getElementById('world')
.addEventListener('mouseup', handleMouseUp, false);
And add these elements to your index.html
<div id="mintBtn" class="btn" onclick="window.mintPlane()">mint plane</div>
<div id="mintAchievementBtn" class="btn" onclick="window.mintAchievement()">mint achievement</div>
<div id="burnBtn" class="btn" onclick="window.burn()">burn achievement</div>
<div id="login">click to login</div>
<div class="world" id="world"></div>
Great, now you'll have a button that makes a modal appear
3. Deploy a Collectibles Contract
You'll need to create a collectible from the Sequence Builder which can be accomplished with the following guide
We should create 2 collections: 1 for achievement tokens and the other for the planes
4. Deploy a Remote Minter & Mint In-game Achievement Tokens
Then, in order to send transactions to the blockchain in a seamless fashion that are gasless, implement a Cloudflare Worker to mint items from a contract deployed in the previous step, ensuring that the transactions API contract address is inputed as a Minter Role
We will allow there to be multiple paths to mint collectibles: a plane collectible and an achievement collectible.
This is accomplished in the code by adding a key/value of isPlane
to the normal cloudflare request body, and creating an additional if/else
in the cloudflare worker.
You can view the code for this in this github repository
For this guide, we will be running all cloudflare code in a local dev envivronment, which can be accomplished by starting the cloudflare worker in the named project folder, with:
wrangler dev
5. Leverage Items In-game
This section will be broken into 2 implementations of updating UI with in-game asset ownership changes:
- Displaying Plane changes based on Wallet assets
- Displaying UI changes based on Wallet assets
Displaying Plane changes based on Wallet assets
To implement changes to the game based on what the wallet asset owns, you can implement a button that mints a token, then on the response, checks for indexer changes
In the index.js
we include a button attached to the onclick
attribute of the element in index.html
window.mintPlane = () => {
const tokenID = 1
mainScene.sequenceController.callContract(tokenID, true, (res) => {
mainScene.sequenceController.fetchPlaneTokens(tokenID)
})
}
Where callContract
takes care of the minting by calling a fetch that is wrapped in a mutex to ensure 1 mint happens at a time, to prevent click mashers, added to the SequenceController
class in /API/SequenceController.js
import { Mutex, E_CANCELED} from 'async-mutex';
const mutexMinting = new Mutex();
...
async callContract(tokenId, isPlane, callback) {
if(!mutexMinting.isLocked()){
try {
await mutexMinting.runExclusive(async () => {
console.log('Minting token:', tokenId);
const url = 'http://localhost:8787';
const data = {
address: this.walletAddress,
tokenId: tokenId,
isPlane: isPlane
};
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
const txHash = await res.text();
mutexMinting.release();
callback(txHash);
} catch(err) {
mutexMinting.release();
callback(err);
}
});
} catch (err) {
if (err === E_CANCELED) {
mutexMinting.release();
}
}
} else {
console.log('mutex is locked')
}
}
and fetchPlaneTokens
will poll on the result until there's an asset in your wallet, updating the plane color
to represent a different plane.
fetchPlaneTokens
is implemented with the following code, where the balance conditional check is greater than 1, and the tokenID
is equal to the searched for id.
This UI conditional logic would change based on your application
import { SequenceIndexer } from '@0xsequence/indexer';
...
async fetchPlaneTokens(){
// a polling wait
const wait = (ms) => new Promise((res) => setTimeout(res, ms))
let hasFoundPlane = false
while(!hasFoundPlane) {
const response = await this.indexer.getTokenBalances({
accountAddress: this.walletAddress,
contractAddress: '0x10ac72ada55ed46ee35deed371b8d215c2e870e1', // the collection address
})
await wait(1000)
for(let i = 0; i < response.balances.length; i++){
// a check on the inventory
if(response.balances[i].tokenID == '1' && Number(response.balances[i].balance) > 0){
// implement any UI update here
this.scene.airplane.addPlane(Number(response.balances[i].tokenID))
hasFoundPlane = true // breaks from the loop
}
}
}
}
Displaying UI Changes Based on Wallet Assets
Next, we implement a UI change where we add a burn achievement
button, based on if the user has an achievement or not
First, implement the similiar html/js click handler logic like before
where this time, the isPlane
value of callContract
is set to false
// index.js
window.mintAchievement = () => {
const tokenID = 0
mainScene.sequenceController.callContract(tokenID, false, (res) => {
mainScene.sequenceController.fetchTokensFromAchievementMint(tokenID)
})
}
This time, we call fetchTokensFromAchievementMint
which is added to the SequenceController
async fetchTokensFromAchievementMint(tokenID) {
// check for achievement balance
const wait = (ms) => new Promise((res) => setTimeout(res, ms))
let hasFoundPlane = false
let tokenIDs = []
while(!hasFoundPlane) {
const response = await this.indexer.getTokenBalances({
accountAddress: this.walletAddress,
contractAddress: '0x856de99d7647fb7f1d0f60a04c08340db3875340', // you achievements collection address
})
await wait(1000)
for(let i = 0; i < response.balances.length; i++){
// can update this logic to see if there is any balance: i.e. if(response.balances.length > 0)
if(response.balances[i].tokenID == String(tokenID)){
hasFoundPlane = true
// making the button appear
document.getElementById('burnBtn').style.display = 'flex'
}
}
}
}
This makes it so that only if there's a balance returned from the indexer, does the display
attribute make the button appear
6. Burn In-Game Achievement Tokens
Finally, to burn the achievement token, we can no longer use a cloduflare worker for actions sent to the blockchain, because when the minting was performed 'on behalf of' the address using the transactions API (making the msg.sender
in the contract one of the relayer
addresses) for this, we want to make sure the msg.sender
in the contract proves ownership of the token, and is sent directly from the user
. We'll use wagmi
frontend functions as well as some class composition to accomplish this.
// index.js
window.burn = () => {
const tokenID = 0
mainScene.sequenceController.burnToken(tokenID, (res) => {
mainScene.sequenceController.fetchTokensFromBurn(tokenID)
})
}
Where burnToken
is a passed in function from our react component that uses the similiar pattern of using mutexes, and we send the transaction using sendTransaction
from the wagmi
package, and wait for a transaction hash update to return the callback
// react/Login.jsx
import {
useAccount,
useWalletClient,
useSendTransaction,
} from 'wagmi';
import { useMutex } from 'react-context-mutex';
import { ethers } from 'ethers'
import { SequenceIndexer } from '@0xsequence/indexer';
let burnCallback = null
const ContractAddress = '0x856de99d7647fb7f1d0f60a04c08340db3875340';
function Login() {
const MutexRunner = useMutex();
const mutexBurn = new MutexRunner('sendMutexBurn');
const { isConnected } = useAccount()
const { data: walletClient } = useWalletClient();
const { data: txnData, sendTransaction, isLoading: isSendTxnLoading } = useSendTransaction();
useEffect(() => {
if (isConnected && walletClient) {
props.scene.sequenceController.init(
walletClient,
sendBurnToken
);
}
}, [isConnected, walletClient]);
const sendBurnToken = async (tokenID, callback) => {
if(!mutexBurn.isLocked()){
const contractABI = ['function burn(uint256 tokenId, uint256 amount)']; // Replace with your contract's ABI
const contract = new ethers.Contract(ContractAddress, contractABI);
// call indexer
// check for achievement balance
const indexer = new SequenceIndexer(
'https://arbitrum-sepolia-indexer.sequence.app',
process.env.PROJECT_ACCESS_KEY
);
const response = await indexer.getTokenBalances({
accountAddress: walletClient.account.address,
contractAddress: '0x856de99d7647fb7f1d0f60a04c08340db3875340',
})
const data = contract.interface.encodeFunctionData('burn', [
tokenID,
response.balances[0].balance, // get the balance from the indexer
]);
try {
mutexBurn.lock()
burnCallback = callback
await sendTransaction({
to: ContractAddress,
data: data,
value: '0',
gas: null,
})
} catch (error) {
console.log(error)
callback(error);
}
} else {
console.log('burn in progress')
}
};
useEffect(() => {
if(txnData && burnCallback && mutexBurn.isLocked()) {
mutexBurn.unlock();
burnCallback(txnData)
}
}, [burnCallback, txnData])
...
}
And in our SequenceController
, call the sendBurnToken
function wrapped in burnToken
to make the react function accessible to the rest of the application
async burnToken(tokenID, callback) {
this.sendBurnToken(tokenID, callback);
}
async init(walletClient, sendTransactionBurn) {
this.walletAddress = walletClient.account.address;
this.sendBurnToken = sendTransactionBurn;
}
Then, to make the burned token have an affect on the UI, we hide the button used to burn the token in the initial place, accomplished with the following code in the SequenceController
async fetchTokensFromBurn(tokenID){
const wait = (ms) => new Promise((res) => setTimeout(res, ms))
let hasBeenBurned = false
while(!hasBeenBurned) {
let tokenIDs = [] // create an empty array to include all the tokens
const response = await this.indexer.getTokenBalances({
accountAddress: this.walletAddress,
contractAddress: '0x856de99d7647fb7f1d0f60a04c08340db3875340',
})
await wait(1000)
for(let i = 0; i < response.balances.length; i++){
tokenIDs.push(response.balances[i].tokenID)
}
if(!tokenIDs.includes(String(tokenID))) { // check that the token id is not contained in the array
hasBeenBurned = true
// can apply any UI logic here
document.getElementById('burnBtn').style.display = 'none' // hide the button
}
}
}
And you're done, you can view a full example of the code here
7. (Optional) Integrate Embedded Wallet Into Sequence Kit
If you'd like to smooth the user journey to allow no user transaction signing across the board, you can enable an Embedded Wallet by updating your configuration of your Sequence Kit react component.
By accomplishing this, we reduce a pop-up when burning tokens with wagmi
, since rewarding achievement tokens and minting collectibles are completed using a cloudflare worker for gasless transactions.
This can be accomplished by adding a few environment variables, and switching the type of connector we use.
First update your .env
file with the following environment secrets
WAAS_CONFIG_KEY=
GOOGLE_CLIENT_ID=
APPLE_CLIENT_ID=
Then pass these variables to your sequence kit connector in App.jsx
import { getKitConnectWallets } from '@0xsequence/kit';
import { getDefaultWaasConnectors } from '@0xsequence/kit-connectors';
import { createConfig, http, WagmiProvider } from 'wagmi';
...
const projectAccessKey = process.env.PROJECT_ACCESS_KEY_NEXT;
const waasConfigKey = process.env.WAAS_CONFIG_KEY;
const googleClientId = process.env.GOOGLE_CLIENT_ID;
const appleClientId = process.env.APPLE_CLIENT_ID;
function App(props) {
const appleRedirectURI =
'https://' + window.location.host + '/aviator-demo';
const connectors = [
...getDefaultWaasConnectors({
walletConnectProjectId: process.env.WALLET_CONNECT_ID,
defaultChainId: 421614,
waasConfigKey,
googleClientId,
appleClientId,
appleRedirectURI,
appName: 'demo app',
projectAccessKey,
enableConfirmationModal: false,
}),
...getKitConnectWallets(projectAccessKey, []),
];
const transports = {};
chains.forEach(chain => {
transports[chain.id] = http();
});
const config = createConfig({
transports,
connectors,
chains,
});
return (
<WagmiProvider config={config}>
...
<WagmiProvider/>
)
}
And that's it, no more integration is required for transaction flows to complete