How to create a 3D metaverse in React (part 2/3)

How to create a 3D metaverse in React (part 2/3)

ยท

25 min read

This is the second part of the tutorial series in which we build a metaverse from scratch. In the first part we already created the 3D world, enabled player movement and added objects to the world. If you missed the first article I would highly recommend to check it out before proceeding with this part.

Part 1: How to create a 3D metaverse in React (part 1/3)

This part is going to cover many of the Web3-related things of the project, like implementing the wallet connection functionality, creating and deploying an NFT collection, adding the mintable item which is stored on IPFS to the game and writing the code necessary to fetch and display NFTs. Without further ado let's go!

Implement wallet connection

Now this step is going to be rather easy thanks to wagmi and RainbowKit. Let's start by creating a new component called Web3Providers.tsx. Next, declare that it is a client component with 'use client'. This needs to be done because Web3 wallets can't be accessed and interacted with from the server.

'use client';

Now import the following things that we'll need within this component:

import { getDefaultWallets, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { configureChains, createConfig, WagmiConfig } from 'wagmi';
import { polygonMumbai } from 'wagmi/chains';
import { publicProvider } from 'wagmi/providers/public';

Next up call the configureChains function and pass it the chain that we want to support, Polygon Mumbai and the information of our provider.


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

We pass it a public provider since there will be not that much traffic on the website. The public provider has a limit in terms of how many requests can be made. So if you need a bigger quota you should consider using Alchemy or Infura as the provider.

In our app we want the user to be able to use WalletConnect to connect with our website. Since v2 WalletConnect requires you to provide a WalletConnect project ID. You can retrieve your project ID for free. To get the project ID visit https://cloud.walletconnect.com/, sign in with your wallet and create a project. After that create a new file called .env.local inside the root of your directory and add a new environment variable called NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID to it. Copy your WalletConnect project ID and assign it to the variable.

NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=yourWalletConnectProjectId

Next, get the connectors (the wallets) by calling the getDefaultWallets method from RainbowKit and pass it the app's name as well as the project ID.

const { connectors } = getDefaultWallets({
  appName: 'Metaverse Demo',
  projectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID!,
  chains,
});

Now create the configuration object for wagmi.

const wagmiConfig = createConfig({
  autoConnect: true,
  connectors,
  publicClient,
});

We set autoConnect to true so that a wallet connection is automatically continued after e.g. a page reload and pass it the connectors as well as the public client.

Now, all that's left to do is to write the actual component logic. We return the wagmi and RainbowKit provider components with the props necessary for them to work properly and wrap the children prop with them.

type Props = {
  children: React.ReactNode;
};

export const Web3Providers = ({ children }: Props) => {
  return (
    <WagmiConfig config={wagmiConfig}>
      <RainbowKitProvider chains={chains}>{children}</RainbowKitProvider>
    </WagmiConfig>
  );
};

Done. The file should now look like this:

'use client';

import { getDefaultWallets, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { configureChains, createConfig, WagmiConfig } from 'wagmi';
import { polygonMumbai } from 'wagmi/chains';
import { publicProvider } from 'wagmi/providers/public';

const { chains, publicClient } = configureChains(
  [polygonMumbai],
  [publicProvider()]
);
const { connectors } = getDefaultWallets({
  appName: 'Metaverse Demo',
  projectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID!,
  chains,
});
const wagmiConfig = createConfig({
  autoConnect: true,
  connectors,
  publicClient,
});

type Props = {
  children: React.ReactNode;
};

export const Web3Providers = ({ children }: Props) => {
  return (
    <WagmiConfig config={wagmiConfig}>
      <RainbowKitProvider chains={chains}>{children}</RainbowKitProvider>
    </WagmiConfig>
  );
};

Now, within the app, we need a connect wallet button. Create a new component called Header.tsx and return the ConnectButton component from RainbowKit in it. Make sure to use the 'use client' annotation at the top because RainbowKit requires the component to be a client component. Style the component to your liking or use the same styles as me, it's up to you.

'use client';

import { ConnectButton } from '@rainbow-me/rainbowkit';

export const Header = () => {
  return (
    <header className="py-2 fixed top-0 inset-x-0 bg-gray-200/25 backdrop-blur-sm">
      <div className="container flex justify-end">
        <ConnectButton />
      </div>
    </header>
  );
};

Now let's include both, the Web3Providers and the Header component in layout.tsx to include them in our app. Additionally, import the styles.css file from RainbowKit in order for the connect wallet button to be properly styled.

import { Web3Providers } from '@/components/Web3Providers';
import './globals.css';
import { Inter } from 'next/font/google';
import { Header } from '@/components/Header';
import '@rainbow-me/rainbowkit/styles.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
  title: 'Metaverse Demo',
  description:
    'This dApp features a 3D metaverse with a mintable in-game item.',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Web3Providers>
          {children}
          <Header />
        </Web3Providers>
      </body>
    </html>
  );
}

If you check your browser now you'll most likely be confronted with this error:

Fear not this is because Webpack used to include polyfills for Node.js modules like fs but doesn't do it anymore since version 5. Head over to next.config.js and change the file like so.

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    config.resolve.fallback = {
      ...config.resolve.fallback,
      fs: false,
      net: false,
      tls: false,
    };
    return config;
  },
};

module.exports = nextConfig;

Since the modules seem to not actually be used within the code we can safely just suppress the error by telling Webpack to not resolve these modules.

Now everything should work fine again. Here's the result:

Only one thing left that needs to be improved a little bit: If we click on the header or on the connect wallet button sometimes our pointer gets locked. We don't want this behaviour. If the user clicks on some HUD elements the pointer shouldn't get locked into our 3D experience. In order to fix that we'll add an event listener to the header component that stops the propagation of a click event so that it doesn't trigger any other listeners. Inside Header.tsx add an event listener to the header element.

'use client';

import { ConnectButton } from '@rainbow-me/rainbowkit';

export const Header = () => {
  return (
    <header
      className="py-2 fixed top-0 inset-x-0 bg-gray-200/25 backdrop-blur-sm"
      onClick={(e) => e.nativeEvent.stopImmediatePropagation()}
    >
      <div className="container flex justify-end">
        <ConnectButton />
      </div>
    </header>
  );
};

We can't stop the propagation of the React event and instead have to stop the propagation of the native event. This needs to be done because the event listeners that trigger the pointer lock are added to the document element in the native and not React way.

Great, with that we successfully have our wallet connection set up.

Create NFT collection using HeyMint

To create an NFT collection in the easiest and fastest way possible we are going to use the free tool HeyMint by Alchemy. With HeyMint we can create an NFT collection with the data being stored on IPFS in just a few minutes. But before that, we actually need to have the data which we want to turn into an NFT. For the tutorial, we are going to use a 3D sword model. Luckily, Kenney got us covered again and we can download and use a sword model that he created. Head over to pmndrs market and click on "Download Model".

After the file has been successfully downloaded we need to convert it because OpenSea is not able to properly show the file otherwise. Head over to https://sbtron.github.io/makeglb/ and drop the newly downloaded file in the designated area.

After that, you should have a .glb file which we can use as metadata for our NFT. Additionally, we need a thumbnail image for our NFT. What I did was to just screenshot the sword model on pmndrs market and remove the background to get the following image. Feel free to use any other image if you want to.

Great, now we're ready to create our NFT collection. For this step, I've created a quick Loom showing you the entire flow. Alternatively, if you'd rather follow a written guide I found this article by Kate Tadifa to be very helpful.

Note: In the video, I accidentally set the price to be 0.1001 MATIC instead of 0.0001 MATIC. Luckily the HeyMint dashboard allows changes after deployment so I changed it to the intended 0.0001 MATIC which is not displayed in the video.

A few points in the Loom I want to highlight:

  • HeyMint takes a cut for each mint which is 0.1 MATIC. Therefore the end value each mint will cost is 0.1 MATIC + TOKEN_PRICE.

  • Verify the contract on Polygon Scan to display the source code and be able to invoke methods from the smart contract from within their website.

  • Don't forget to start the sale after the contract has been deployed. Otherwise invoking the mint function from the app will throw an error.

Now copy the contract address and store it inside an environment variable next to the NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID environment variable.

NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=yourWalletConnectProjectId
NEXT_PUBLIC_CONTRACT_ADDRESS=0xE9631c8FBae5203091657B148090119413400521

Add the sword model stored on IPFS to the metaverse

We will add the sword to our 3D world in the same way we did before in part 1 where we added all other models. But afterwards, we will replace the URL used inside the useGLTF hook with the IPFS URL of our sword. That way instead of fetching the data from the public/assets/models/ folder we fetch it from decentralized storage.

Move the .glb file of the sword, that you retrieved in the preceding step to the public/assets/models/ folder, rename it to sword.glb and execute the gltfjsx command again.

npx gltfjsx public/assets/models/sword.glb -o src/components/Sword.tsx -t -s -r public

You should end up with a new Sword component.

/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
Command: npx gltfjsx@6.2.3 public/assets/models/sword.glb -o src/components/Sword.tsx -t -s -r public
*/

import * as THREE from 'three'
import React, { useRef } from 'react'
import { useGLTF } from '@react-three/drei'
import { GLTF } from 'three-stdlib'

type GLTFResult = GLTF & {
  nodes: {
    sword_1: THREE.Mesh
    sword_2: THREE.Mesh
    sword_3: THREE.Mesh
    sword_4: THREE.Mesh
  }
  materials: {
    ['textile.006']: THREE.MeshStandardMaterial
    ['stone.004']: THREE.MeshStandardMaterial
    ['wood.022']: THREE.MeshStandardMaterial
    ['_defaultMat.005']: THREE.MeshStandardMaterial
  }
}

export function Model(props: JSX.IntrinsicElements['group']) {
  const { nodes, materials } = useGLTF('/assets/models/sword.glb') as GLTFResult
  return (
    <group {...props} dispose={null}>
      <group position={[-2.663, 0.068, -8.576]}>
        <mesh castShadow receiveShadow geometry={nodes.sword_1.geometry} material={materials['textile.006']} />
        <mesh castShadow receiveShadow geometry={nodes.sword_2.geometry} material={materials['stone.004']} />
        <mesh castShadow receiveShadow geometry={nodes.sword_3.geometry} material={materials['wood.022']} />
        <mesh castShadow receiveShadow geometry={nodes.sword_4.geometry} material={materials['_defaultMat.005']} />
      </group>
    </group>
  )
}

useGLTF.preload('/assets/models/sword.glb')

Next, let's get the IPFS URL of our model. For this step to work, we need to use an IPFS gateway. We want to get the data that is stored on IPFS but since there is no native way of fetching data using the IPFS protocol in most browsers yet we need to use a service like Infura which serves us the IPFS content over a conventional HTTP request. Head over to Infura and log in or create an account if you don't have one yet. Then create a new API key by pressing the button in the top right corner.

Select IPFS and give a name to your key.

Then enable dedicated gateways, choose a subdomain name of your liking and save it.

Your dedicated gateway URL should be displayed at the top now. In my case it is https://metaverse-demo.infura-ipfs.io. Copy this URL and paste it in .env.local. Make sure to add /ipfs to the end of the URL. Otherwise, the URL doesn't seem to be valid.

NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=yourWalletConnectProjectId
NEXT_PUBLIC_CONTRACT_ADDRESS=0xE9631c8FBae5203091657B148090119413400521
NEXT_PUBLIC_INFURA_IPFS_GATEWAY_BASE_URL=https://metaverse-demo.infura-ipfs.io/ipfs

Great, now we have our IPFS gateway set up. Next up we need to find out what the CID (the identifier) of our sword is in order to be able to fetch that data using our gateway. Unfortunately HeyMint doesn't provide us with that information in their dashboard. But we can find the information within the smart contract data. Go to https://mumbai.polygonscan.com/ and search for your contract using your contract address.

Then Click on "Contract" and "Read as Proxy".

You might be wondering why we have to read as a proxy and can't directly read from our deployed contract. The reason is that our contract is just a proxy contract and doesn't have any implementation details. So PolygonScan doesn't know what kind of functions we can call on our contract or what its state is. However, it found a contract which serves as an interface for the functions we can call on our smart contract and that get proxied to the implementation contracts. By invoking functions in "Read as Proxy" or "Write as Proxy" we call functions or read state according to the interface. These functions however are called on the proxy contract and proxied to the implementation contracts by the proxy contract.

With that being said check the baseTokenURI.

This is the base URI each token's URI starts with. If you use the Brave browser (or any other browser that supports IPFS natively) you can now just open a new tab, paste that URL and append a /1 at the end to get the token metadata of the first token of our collection.

ipfs://bafybeid4kb7zphjrrnpycj52frp6qyztl7sqkyrpchrtikpzy6zs2wozt4/1

For every other browser we need to replace the ipfs:// with the Infura IPFS gateway base URL and append it with the /1 for the token metadata of the first token.

https://metaverse-demo.infura-ipfs.io/ipfs/bafybeid4kb7zphjrrnpycj52frp6qyztl7sqkyrpchrtikpzy6zs2wozt4/1

Now within a new tab use either the ipfs:// or https:// URL depending on your browser and hit enter. You should get the JSON metadata of the first token.

Since we defined that there should be a 10,000 token supply there are 10,000 metadata objects on IPFS. You could get the metadata of the second token by appending the URL with a /2 instead of a /1 etc.

However, since we created 10,000 tokens with the same thumbnail image and 3D model the image and animation_url property will be the same for all 10,000 tokens.

Our 3D model is stored under the animation_url property.

Now go back to Sword.tsx, add a new constant SWORD_MODEL_IPFS_URL at the top of the file setting its value to the value of the animation_url property. But instead of using ipfs:// we want to use the environment variable that stores our IPFS gateway base URL. That way we can fetch the data from IPFS no matter which browser is used.

const SWORD_MODEL_IPFS_URL = `${process.env.NEXT_PUBLIC_INFURA_IPFS_GATEWAY_BASE_URL}/bafybeih7455dnvglj6aur6xzhewlpymmxpmzd5ailld4kjfmaqyfdszvvm/1`;

Now replace the URL assets/models/Sword.glb inside useGLTF with this variable. Also, replace the URL inside useGLTF.preload with the same variable. Your file should now look like this:

/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
Command: npx gltfjsx@6.2.3 public/assets/models/sword.glb -o src/components/Sword.tsx -t -s -r public
*/

import * as THREE from 'three';
import React, { useRef } from 'react';
import { useGLTF } from '@react-three/drei';
import { GLTF } from 'three-stdlib';

const SWORD_MODEL_IPFS_URL = `${process.env.NEXT_PUBLIC_INFURA_IPFS_GATEWAY_BASE_URL}/bafybeih7455dnvglj6aur6xzhewlpymmxpmzd5ailld4kjfmaqyfdszvvm/1`;

type GLTFResult = GLTF & {
  nodes: {
    sword_1: THREE.Mesh;
    sword_2: THREE.Mesh;
    sword_3: THREE.Mesh;
    sword_4: THREE.Mesh;
  };
  materials: {
    ['textile.006']: THREE.MeshStandardMaterial;
    ['stone.004']: THREE.MeshStandardMaterial;
    ['wood.022']: THREE.MeshStandardMaterial;
    ['_defaultMat.005']: THREE.MeshStandardMaterial;
  };
};

export function Model(props: JSX.IntrinsicElements['group']) {
  const { nodes, materials } = useGLTF(SWORD_MODEL_IPFS_URL) as GLTFResult;
  return (
    <group {...props} dispose={null}>
      <group position={[-2.663, 0.068, -8.576]}>
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.sword_1.geometry}
          material={materials['textile.006']}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.sword_2.geometry}
          material={materials['stone.004']}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.sword_3.geometry}
          material={materials['wood.022']}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.sword_4.geometry}
          material={materials['_defaultMat.005']}
        />
      </group>
    </group>
  );
}

useGLTF.preload(SWORD_MODEL_IPFS_URL);

Great, now we're able to remove the sword.glb file from public/assets/models because it won't be served from this directory anymore.

Lastly, include the Sword component in your Experience.tsx file.

import { Sky } from '@react-three/drei';
import { Ground } from './Ground';
import { Lighting } from './Lighting';
import { FirstPersonControls } from './FirstPersonControls';
import { Flora } from './Flora';
import { Model as Table } from './TableCrossCloth';
import { Vendor } from './Vendor';
import { Model as Sword } from './Sword';

export const Experience = () => {
  const sunPosition = [2, 6, 4] as const;

  return (
    <>
      <Lighting sunPosition={sunPosition} />
      <Sky sunPosition={sunPosition} />
      <Flora />
      <Ground />
      <FirstPersonControls />
      <Table scale={1.75} position={[0, 0, 0]} />
      <Vendor />
      <Sword position={[0.28, -2.005, 8.32]} rotation={[0, 0, -1.64]} />
    </>
  );
};

I additionally added a fitting position and rotation to the Sword component.

If you check out the browser now you should see the sword being placed on top of the table and the best of it is that the data for that model comes from the decentralized storage network IPFS.

Amazing! We've already come a long way. Let's now add a toolbar which fetches the NFTs of the connected account and displays them.

Implement a toolbar which fetches and displays all NFTs owned by the player

Let's create a little toolbar which shows the player the NFTs he owns. For each NFT the player minted (each sword NFT) we are going to display one sword item in the toolbar. When the player clicks on that item we want to display that specific NFT on OpenSea.

Toolbar component

Create a new component called Toolbar.tsx inside src/components/. At the top of the file define the URL to the OpenSea collection.

const openSeaCollectionURL = `https://testnets.opensea.io/assets/mumbai/${process.env.NEXT_PUBLIC_CONTRACT_ADDRESS}`;

We want to pass the items the toolbar should display as props. So let's define a type for these items called ToolbarItem.

export type ToolbarItem = {
  thumbnailURL: string;
  name: string;
  id: string;
};

Next, create the ToolbarProps type.

type ToolbarProps = {
  items: ToolbarItem[];
};

Now we're ready for the component code. Create the component function. Since we are going to pass all items of the user but only want to display max. 10 items in the 10 slots of our toolbar we have to slice the items array.

export const Toolbar = ({ items }: ToolbarProps) => {
  const slots: (ToolbarItem | null)[] = new Array(10).fill(null);
  items.slice(0, 10).forEach((item, index) => (slots[index] = item));
};

Now, the slot will be null if there is no item filling it and otherwise the slot is a ToolbarItem.

Next, import Image from next/image at the top of the file and create the return statement like so:

return (
  <ul className="fixed left-1/2 -translate-x-1/2 bottom-4 flex gap-2 border-gray-300 border border-solid rounded-lg shadow-lg p-2 bg-gray-200/25 backdrop-blur-sm">
    {slots.map((slot, index) => (
      <li key={index} className="w-12 h-12 bg-white/40 rounded-md ">
        {slot && (
          <a
            className="block relative w-full h-full hover:scale-110 transition-transform"
            title={`Show ${slot.name} on OpenSea`}
            href={`${openSeaCollectionURL}/${slot.id}`}
            target="_blank"
            rel="noopener noreferrer"
          >
            <Image
              src={slot.thumbnailURL}
              fill
              alt=""
              className="object-cover rounded-md"
            />
          </a>
        )}
      </li>
    ))}
  </ul>
);

Just like that, we created a list showing all our slots. If the slot is filled with an item we are showing that item and wrapping the thumbnail image with a link to OpenSea.

The whole file should now look like this:

import Image from 'next/image';

const openSeaCollectionURL = `https://testnets.opensea.io/assets/mumbai/${process.env.NEXT_PUBLIC_CONTRACT_ADDRESS}`;

export type ToolbarItem = {
  thumbnailURL: string;
  name: string;
  id: string;
};

type ToolbarProps = {
  items: ToolbarItem[];
};

export const Toolbar = ({ items }: ToolbarProps) => {
  const slots: (ToolbarItem | null)[] = new Array(10).fill(null);
  items.slice(0, 10).forEach((item, index) => (slots[index] = item));

  return (
    <ul className="fixed left-1/2 -translate-x-1/2 bottom-4 flex gap-2 border-gray-300 border border-solid rounded-lg shadow-lg p-2 bg-gray-200/25 backdrop-blur-sm">
      {slots.map((slot, index) => (
        <li key={index} className="w-12 h-12 bg-white/40 rounded-md ">
          {slot && (
            <a
              className="block relative w-full h-full hover:scale-110 transition-transform"
              title={`Show ${slot.name} on OpenSea`}
              href={`${openSeaCollectionURL}/${slot.id}`}
              target="_blank"
              rel="noopener noreferrer"
            >
              <Image
                src={slot.thumbnailURL}
                fill
                alt=""
                className="object-cover rounded-md"
              />
            </a>
          )}
        </li>
      ))}
    </ul>
  );
};

There is one more thing we need to do for this code to work properly later on. Our thumbnail image will be served using the IPFS gateway that we set up earlier. However, the Next.js Image component by default does only accept URLs pointing to the same origin. Additional configuration is needed to whitelist the domain of the IPFS gateway server. Head over to next.config.js and include the domain of your dedicated IPFS gateway. In my case it is metaverse-demo.infura-ipfs.io.

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    config.resolve.fallback = {
      ...config.resolve.fallback,
      fs: false,
      net: false,
      tls: false,
    };
    return config;
  },
  images: {
    domains: ['metaverse-demo.infura-ipfs.io'],
  },
};

module.exports = nextConfig;

Great, now Next.js shouldn't complain once we provide a thumbnail image URL of one of our NFTs.

Now create a new component called HUD.tsx inside src/components/ and return the Toolbar component from it. For now, just pass an empty array as the items prop.

import { Toolbar } from './Toolbar';

export const HUD = () => {
  return <Toolbar items={[]} />;
};

Use the new HUD component inside page.tsx. Place the component outside of the Canvas component since it is normal HTML and not 3D-related.

'use client';

import { Experience } from '@/components/Experience';
import { HUD } from '@/components/HUD';
import { KeyboardControlsEntry, KeyboardControls } from '@react-three/drei';
import { Canvas } from '@react-three/fiber';

export type Controls = 'forward' | 'backward' | 'left' | 'right';

export default function Home() {
  const map: KeyboardControlsEntry<Controls>[] = [
    { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
    { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
    { name: 'left', keys: ['ArrowLeft', 'KeyA'] },
    { name: 'right', keys: ['ArrowRight', 'KeyD'] },
  ];

  return (
    <main className="h-screen">
      <Canvas shadows camera={{ position: [0, 1, 2] }}>
        <KeyboardControls map={map}>
          <Experience />
        </KeyboardControls>
      </Canvas>
      <HUD />
    </main>
  );
}

Now is a good time to check the browser again.

Cool. We have our toolbar set up.

Fetch NFTs to be displayed in the toolbar

Let's now fetch the NFTs that we own. Spoiler: Since we didn't mint any NFTs yet there won't be any NFTs displayed as of now. However, once we implement the minting functionality they will show up.

In order for us to interact with our deployed contract we need to get the contract ABI. Head over to PolygonScan and search for your NFT collection using your contract address. Now click on "Contract" and "Read as Proxy" just like in the step where we were looking for the baseTokenURI of our NFT metadata.

Now click on the address of the implementation contract.

Like mentioned before our contract is just a proxy contract and forwards our function calls. It doesn't provide us with an ABI that tells us which functions we can invoke on it. The implementation contract however has the ABI we need and which must be used to invoke functions on our contract.

Once you are on the page for the implementation contract, scroll down to the section "Contract ABI" and click on the copy icon to copy the ABI to the clipboard.

Create a new directory called utils under src/ and a new directory in it called abis. Now create a new file with the name ditem.ts or any other name that matches your smart contract. Then create an exported constant called abi in it and assign it the ABI that you copied to your clipboard like so:

export const abi = [
  {
    inputs: [],
    name: 'CORI_SUBSCRIPTION_ADDRESS',
    outputs: [{ internalType: 'address', name: '', type: 'address' }],
    stateMutability: 'view',
    type: 'function',
  },
  {
    inputs: [],
    name: 'EMPTY_SUBSCRIPTION_ADDRESS',
    outputs: [{ internalType: 'address', name: '', type: 'address' }],
    stateMutability: 'view',
    type: 'function',
  },
  ...

Make sure to add an as const at the end of the ABI array. This needs to be done in order for wagmi to pick up the correct types of our ABI.

  ...
  {
    inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }],
    name: 'userOf',
    outputs: [{ internalType: 'address', name: '', type: 'address' }],
    stateMutability: 'view',
    type: 'function',
  },
  {
    inputs: [],
    name: 'withdraw',
    outputs: [],
    stateMutability: 'nonpayable',
    type: 'function',
  },
] as const;

Next, create a new directory called hooks under src/. Inside the hooks directory create a new file called useNFTs.ts. No .tsx ending is necessary since we will not use any elements or components inside here.

Import the ABI inside the newly created file. Additionally, add two more necessary import statements.

import { abi } from '@/utils/abis/ditem';
import { useState, useEffect } from 'react';
import { useAccount, useContractRead } from 'wagmi';

Then create an object called contract which stores information about our contract that we'll reference multiple times within this file.

const contract = {
  address: process.env.NEXT_PUBLIC_CONTRACT_ADDRESS as `0x${string}`,
  abi,
} as const;

Create a type TokenMetadata.

type TokenMetadata = {
  name: string;
  description: string;
  imageURL: string;
  animationURL: string;
};

And a type Token which additionally includes the type information for the id of each token.

export type Token = TokenMetadata & {
  id: bigint;
};

Now, create the useNFTs function and use the following hooks in it.

export const useNFTs = () => {
  const { address } = useAccount();
  const [tokens, setTokens] = useState<Token[]>([]);
  const { data: tokenIds } = useContractRead({
    enabled: !!address,
    ...contract,
    functionName: 'tokensOfOwner',
    args: address ? [address] : undefined,
    watch: true,
  });
};

What this code does is the following:

  • It gets the address of the currently connected account or undefined if no wallet is connected through the use of the useAccount hook.

  • It creates a stateful variable tokens where we want to store the tokens owned by the connected account later on.

  • It uses the useContractRead hook from wagmi. Within this hook the contract function that we want to invoke tokensOfOwner is defined and the address is passed as an argument for that function if a wallet is connected. Additionally, we pass the contract information we stored in the contract variable and set watch to be true. With watch set to true the tokenIds are updated on every new block. We don't want the hook to run when there is no connected account so we provide the enabled property and only set it to true when address is truthy. The hook returns the tokenIds of the connected account or undefined if no account is connected.

Next, we want to execute some code whenever the tokenIds change. Perfect for using the useEffect hook. Add the useEffect hook with the following content:

useEffect(() => {
  if (!tokenIds) return;

  // Do something
}, [tokenIds]);

Now create a function called parseIPFSURL inside useEffect which will help us parse the IPFS URLs.

const parseIPFSURL = (ipfsURL: string) => {
  const ipfsCID = ipfsURL.replace('ipfs://', '');
  return `${process.env.NEXT_PUBLIC_INFURA_IPFS_GATEWAY_BASE_URL}/${ipfsCID}`;
};

The function returns a traditional HTTP URL with the help of the IPFS gateway we set up earlier.

Next, create a function called fetchTokenURI inside useEffect which has a tokenId as a parameter and fetches the corresponding token URI. This function calls the readContract function from wagmi. Make sure to import it from wagmi/actions at the top of the file. The readContract function expects a config object. Inside that object, we set the functionName property to tokenURI and pass the tokenId as an argument to the contract function using the args property. Additionally, we pass the general contract information that we stored earlier inside the contract variable.

const fetchTokenURI = async (tokenId: bigint) => {
  const tokenURI = await readContract({
    ...contract,
    functionName: 'tokenURI',
    args: [tokenId],
  });
  return tokenURI;
};

Following that, inside useEffect create a function called fetchToken with the following content:

const fetchToken = async (tokenId: bigint) => {
  const tokenURI = await fetchTokenURI(tokenId);
  const ipfsGatewayURL = parseIPFSURL(tokenURI);
  const res = await fetch(ipfsGatewayURL);
  const tokenMetadata = (await res.json()) as Omit<
    TokenMetadata,
    'animationURL' | 'imageURL'
  > & {
    animation_url: string;
    image: string;
  };
  const token: Token = {
    id: tokenId,
    name: tokenMetadata.name,
    description: tokenMetadata.description,
    imageURL: parseIPFSURL(tokenMetadata.image),
    animationURL: parseIPFSURL(tokenMetadata.animation_url),
  };
  return token;
};

The function does the following:

  • Fetches the token URI with the help of the fetchTokenURI function

  • Gets the IPFS gateway URL with the help of the parseIPFSURL function

  • Fetches the token metadata using the IPFS gateway URL

  • Parses the token metadata so that the IPFS URLs are IPFS gateway URLs

Now let's invoke that function for every token ID. Inside useEffect create a function called fetchTokens which does exactly that.

const fetchTokens = async (tokenIds: readonly bigint[]) => {
  const tokenPromises = tokenIds.map(fetchToken);
  const tokens = await Promise.all(tokenPromises);
  return tokens;
};

Then, create a function fetchData inside useEffect which calls fetchTokens, has some basic error handling and sets the stateful variable tokens.

const fetchData = async () => {
  try {
    const tokens = await fetchTokens(tokenIds);
    setTokens(tokens);
  } catch (err) {
    console.error(err);
  }
};

Lastly, call the fetchData inside useEffect. Your useEffect should now look like this:

useEffect(() => {
  if (!tokenIds) return;

  const fetchData = async () => {
    try {
      const tokens = await fetchTokens(tokenIds);
      setTokens(tokens);
    } catch (err) {
      console.error(err);
    }
  };

  const fetchTokens = async (tokenIds: readonly bigint[]) => {
    const tokenPromises = tokenIds.map(fetchToken);
    const tokens = await Promise.all(tokenPromises);
    return tokens;
  };

  const fetchToken = async (tokenId: bigint) => {
    const tokenURI = await fetchTokenURI(tokenId);
    const ipfsGatewayURL = parseIPFSURL(tokenURI);
    const res = await fetch(ipfsGatewayURL);
    const tokenMetadata = (await res.json()) as Omit<
      TokenMetadata,
      'animationURL' | 'imageURL'
    > & {
      animation_url: string;
      image: string;
    };
    const token: Token = {
      id: tokenId,
      name: tokenMetadata.name,
      description: tokenMetadata.description,
      imageURL: parseIPFSURL(tokenMetadata.image),
      animationURL: parseIPFSURL(tokenMetadata.animation_url),
    };
    return token;
  };

  const fetchTokenURI = async (tokenId: bigint) => {
    const tokenURI = await readContract({
      ...contract,
      functionName: 'tokenURI',
      args: [tokenId],
    });
    return tokenURI;
  };

  const parseIPFSURL = (ipfsURL: string) => {
    const ipfsCID = ipfsURL.replace('ipfs://', '');
    return `${process.env.NEXT_PUBLIC_INFURA_IPFS_GATEWAY_BASE_URL}/${ipfsCID}`;
  };

  fetchData();
}, [tokenIds]);

Two more things to do and we're done with the useNFTs hook.

Right now if a user disconnects we don't update the tokens variable. To fix that include one more useEffect like this.

useEffect(() => {
  // When a user disconnects the state should be cleared
  if (!address && tokens.length > 0) {
    setTokens([]);
  }
}, [address, tokens]);

Last but not least return the stateful variable tokens from the hook. The entire file should now look like this:

import { abi } from '@/utils/abis/ditem';
import { useEffect, useState } from 'react';
import { useAccount, useContractRead } from 'wagmi';
import { readContract } from 'wagmi/actions';

const contract = {
  address: process.env.NEXT_PUBLIC_CONTRACT_ADDRESS as `0x${string}`,
  abi,
} as const;

type TokenMetadata = {
  name: string;
  description: string;
  imageURL: string;
  animationURL: string;
};

export type Token = TokenMetadata & {
  id: bigint;
};

export const useNFTs = () => {
  const { address } = useAccount();
  const [tokens, setTokens] = useState<Token[]>([]);
  const { data: tokenIds } = useContractRead({
    enabled: !!address,
    ...contract,
    functionName: 'tokensOfOwner',
    args: address ? [address] : undefined,
    watch: true,
  });

  useEffect(() => {
    if (!tokenIds) return;

    const fetchData = async () => {
      try {
        const tokens = await fetchTokens(tokenIds);
        setTokens(tokens);
      } catch (err) {
        console.error(err);
      }
    };

    const fetchTokens = async (tokenIds: readonly bigint[]) => {
      const tokenPromises = tokenIds.map(fetchToken);
      const tokens = await Promise.all(tokenPromises);
      return tokens;
    };

    const fetchToken = async (tokenId: bigint) => {
      const tokenURI = await fetchTokenURI(tokenId);
      const ipfsGatewayURL = parseIPFSURL(tokenURI);
      const res = await fetch(ipfsGatewayURL);
      const tokenMetadata = (await res.json()) as Omit<
        TokenMetadata,
        'animationURL' | 'imageURL'
      > & {
        animation_url: string;
        image: string;
      };
      const token: Token = {
        id: tokenId,
        name: tokenMetadata.name,
        description: tokenMetadata.description,
        imageURL: parseIPFSURL(tokenMetadata.image),
        animationURL: parseIPFSURL(tokenMetadata.animation_url),
      };
      return token;
    };

    const fetchTokenURI = async (tokenId: bigint) => {
      const tokenURI = await readContract({
        ...contract,
        functionName: 'tokenURI',
        args: [tokenId],
      });
      return tokenURI;
    };

    const parseIPFSURL = (ipfsURL: string) => {
      const ipfsCID = ipfsURL.replace('ipfs://', '');
      return `${process.env.NEXT_PUBLIC_INFURA_IPFS_GATEWAY_BASE_URL}/${ipfsCID}`;
    };

    fetchData();
  }, [tokenIds]);

  useEffect(() => {
    // When a user disconnects the state should be cleared
    if (!address && tokens.length > 0) {
      setTokens([]);
    }
  }, [address, tokens]);

  return {
    tokens,
  };
};

Let's now use the hook inside HUD.tsx, map the returned tokens so that they match the ToolbarItem type we defined in the Toolbar component and pass the mapped tokens to the Toolbar component.

import { Toolbar, ToolbarItem } from './Toolbar';
import { useNFTs } from '@/hooks/useNFTs';

export const HUD = () => {
  const { tokens } = useNFTs();

  const toolbarItems: ToolbarItem[] = tokens.map((token) => ({
    id: token.id.toString(),
    name: token.name,
    thumbnailURL: token.imageURL,
  }));

  return <Toolbar items={toolbarItems} />;
};

Great! With that, we set up the functionality to fetch the NFTs a player owns. We also ensured that if a player receives an NFT either through minting or through a transfer on e.g. OpenSea the tokens variable is updated accordingly leading to a rerender of the toolbar and its items. Therefore, our UI always reflects the current state of the blockchain. We will see that in action as soon as we implement the minting logic.

This concludes the second part of the tutorial series. The last part of the series is all about the interaction with the NPC and about minting.

Last part of the series: How to create a 3D metaverse in React (part 3/3)


Full source code: https://github.com/JonWofr/metaverse-demo
Deployed app: https://metaverse-demo-ten.vercel.app/

ย