Build a Web3 Pokemon Go App (NFT Geo-Location Tutorial)

Build a Web3 Pokemon Go App (NFT Geo-Location Tutorial)

⚠️ Warning: This guide currently uses v4 of the Connect SDK. For v5 (latest) code snippets, please check out our documentation while this guide is being updated. ⚠️

In this guide, learn how to create an app inspired by Pokemon Go — with a website where an NFT can only be claimed if a user is within a certain geolocation of an NFT. Using the user's location and setting locations of NFTs around a map, a user can then travel to certain locations and claim an NFT.

If you prefer watching videos over reading guides, you can check out this video below:

Before we begin, you can access the complete source code for this template on GitHub.

Let's get started!

Setting up thirdweb Engine

We are going to need Docker to set up thirdweb Engine, so make sure you have it installed. If not, go ahead and install it.

Once docker has been installed on your machine, run this command to create a Postgres database:

docker run -p 5432:5432 -e POSTGRES_PASSWORD=postgres -d postgres

Then, to run the engine locally, run this command:

docker run \
  -e THIRDWEB_API_SECRET_KEY="<thirdweb_secret_key>" \
  -e ADMIN_WALLET_ADDRESS="<admin_wallet_address>" \
  -e POSTGRES_CONNECTION_URL="postgresql://postgres:postgres@host.docker.internal:5432/postgres?sslmode=disable" \
   -e ENCRYPTION_PASSWORD="<thirdweb_secret_key>" \
  -p 3005:3005 \
  --pull=always \
  --cpus="0.5" \
  thirdweb/engine:latest

Replace <thirdweb_secret_key> with the secret key generated with your API key. If you haven't created one yet, create it for free on the dashboard. And replace <admin_wallet_address> with your wallet address, which will be the admin wallet of your engine instance.

Your server is running when this log line appears:

Server listening on: http://0.0.0.0:3005

Once your server has started, head over to the thirdweb Engine dashboard and click on "Add Engine Instance":

Add Engine Instance

Once you click on the button, a modal will pop up; choose "import your existing Engine instance":

Import your existing Engine instance

Finally, give your instance a name, provide http://localhost:3005; click as the URL and hit import.

import engine instance with name and url

It should add the engine instance now in your dashboard; click on connect to access the instance dashboard:

Connect to your engine instance

Once you have connected to your engine instance, create a new backend wallet to perform actions:

Create a new backend wallet

Once you have created the wallet, add some funds to the address on the chain where your dapp is going to work.

Finally, head over to the permissions tab and create a new access token:

Create an Access token

Save this access token somewhere safe since we are going to need it in a while.

Deploying an edition drop contract

To begin, head to the Contracts page in your thirdweb Dashboard and hit "Deploy new contract":

deploy new contract

You will be taken to our Explore page, where you can browse smart contracts built by the top protocols in web3 and deploy them with just a few clicks!

Note: You can also use the thirdweb CLI to set up a smart contract environment by running the below command from your terminal:

npx thirdweb create contract

This will take you through an easy-to-follow flow of steps for you to create your contract. Learn more about this in our CLI guide.

Otherwise, let's get back to Explore:

thirdweb explore page


Here, select your smart contract of choice. For this guide, we're going to use the Edition Drop (ERC1155) contract to create our NFT collection:

thirdweb's Edition Drop Contract

Set up your smart contract with an image, name, description, etc., and configure which wallet address will receive the funds from primary and secondary sales:

Populate metadata of your contract

Once you have filled in all the information, select your chain and click on "Deploy Now"!

When your contract completes deploying, you will be redirected to the contract dashboard:

Edition drop dashboard

Go to the NFTs tab and click on the single upload button; if you have a lot of NFTs, you can also choose the batch upload method, but I am simply going to use single upload.

When you click on single upload, a drawer will slide in where you can add the metadata for the NFT:

Lazy mint an nft

In the NFT metadata, make sure to add two properties Latitude and Longitutde which will be the position where the NFT can be minted. You can simply get this using Google Maps, Apple Maps, etc.

Once you have filled in the metadata, click on Lazy Mint NFT and approve the transaction.

After the NFT has been minted, click on it and head over to claim conditions. You can configure these conditions as you wish, but I am going to allow only the engine backend wallet to mint up to 10 NFTs. In this way, others can't directly claim the NFT.

To do so, click on add phase and select allowlist only:

Set claim conditions for your NFT

Then, click on edit claimer snapshot and upload a CSV file with the format as such:

Snapshot.csv

Finally, update the other details of the claim phase and click on save phases:

save claim condition phase

You can add as many such NFTs as you want in the same manner! If you have a lot of NFTs, I recommend using batch upload and batch setting claim conditions.

Creating our app

Using the thirdweb CLI, create a new Next.js & TypeScript project with the React SDK preconfigured for you using the following command:

npx thirdweb create app --next --ts
💡
An API key is required to use thirdweb's infrastructure services, such as storage, RPCs, and Smart Wallet infrastructure from within the SDK. If you haven't created a key yet, you can do so for free from the thirdweb dashboard.

To use an API key with the React SDK, pass the clientId to the ThirdwebProvider. The template already comes with the clientId, so you can simply create a new .env.local file and add the client ID with the respective name in the .env.example file.

We are going to need a few more variables, so let's add that as well:

NEXT_PUBLIC_TEMPLATE_CLIENT_ID=
TW_ENGINE_URL=
TW_ACCESS_TOKEN=
TW_BACKEND_WALLET=
NEXT_PUBLIC_MAP_TOKEN=
  • TW_ENGINE_URL: This is the URL where your engine is hosted. For running it locally, it should be http://localhost:3005
  • TW_ACCESS_TOKEN: This is the access token we created a while back on the engine dashboard
  • TW_BACKEND_WALLET: This is the address of the backend wallet that we also created in the engine dashboard
  • NEXT_PUBLIC_MAP_TOKEN: We are going to use the mapbox API, which requires a token, so this should have that.

Setting up the ThirdwebProvider

Head over to pages/_app.tsx and firstly, change the activeChain variable's value to the chain your dapp is going to use.

Then, we are going to use embedded wallets instead of the normal connect wallet, so pass embeddedWallet in the supportedWallets prop:

<ThirdwebProvider
  clientId={process.env.NEXT_PUBLIC_TEMPLATE_CLIENT_ID}
  activeChain={activeChain}
  supportedWallets={[embeddedWallet()]}
>
  <Component {...pageProps} />
</ThirdwebProvider>

This will now allow your users to sign in via email/google/apple/Facebook, etc. If you want to use some other wallet connect methods, you can do that as well.

Creating the sign-in page

We will create a simple sign-in page that shows a sign-in button if the user hasn't connected their wallet yet, and if they have, then it will show the connected wallet's info.

So, head over to pages/index.tsx and add the following:

import { ConnectWallet, useAddress } from "@thirdweb-dev/react";
import { NextPage } from "next";

const Home: NextPage = () => {
  const address = useAddress();

  if (!address) {
    return (
      <div
        style={{
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
          height: "100vh",
        }}
      >
        <ConnectWallet btnTitle="Sign In" />
      </div>
    );
  }

  return (
    <main>
      <ConnectWallet />
    </main>
  );
};

export default Home;

This will render a simple sign-in page like this:

sign in button

You can try connecting your wallet, and everything should work fine.

Creating the map component

We'll now create a map component where the user will be able to see their location and the location of various NFTs!

So, create a new file in the components folder named Map.tsx and add the following:

import React, { useEffect, useState } from "react";
import { Circle, MapContainer, Marker, TileLayer, useMap } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
import { Coordinates } from "../utils/types/types";

const userIcon = new L.Icon({
  iconUrl: "/user-marker.png",
  iconSize: [25, 41],
  iconAnchor: [12, 41],
  popupAnchor: [1, -34],
});

type MapEventHandlerProps = {
  position: Coordinates;
};

const MapEventHandler: React.FC<MapEventHandlerProps> = ({ position }) => {
  const map = useMap();

  useEffect(() => {
    if (position.lat !== 0 && position.lng !== 0) {
      console.log("Moving map to: ", position);
      map.flyTo([position.lat, position.lng], map.getZoom());
    }
  }, [position, map]);

  return null;
};

const Map = () => {
  const [position, setPosition] = useState<Coordinates>({
    lat: 51.505,
    lng: -0.09,
  });
  let watchId: number;

  const getLocation = () => {
    if (navigator.geolocation) {
      navigator.geolocation.watchPosition(
        (pos) => {
          console.log("Location fetched successfully: ", pos);
          setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude });
        },
        (err) => {
          console.error("Error fetching location: ", err.message);
          alert(
            "Unable to access your location. Please enable location permissions in your browser settings."
          );
        },
        { enableHighAccuracy: true, maximumAge: 10000, timeout: 5000 }
      );
    } else {
      alert("Geolocation is not supported by your browser.");
    }
  };

  useEffect(() => {
    getLocation();

    return () => {
      if (watchId !== undefined) {
        navigator.geolocation.clearWatch(watchId);
      }
    };
  }, []);

  return (
    <MapContainer
      center={[position.lat, position.lng]}
      zoom={16}
      style={{ height: "90vh", width: "100%" }}
    >
      <MapEventHandler position={position} />
      <TileLayer
        url={`https://api.mapbox.com/styles/v1/MAPBOX_USERNAME/MAPBOX_STYLE_ID/tiles/256/{z}/{x}/{y}@2x?access_token=${process.env.NEXT_PUBLIC_MAP_TOKEN}`}
        attribution='Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery &copy; <a href="https://www.mapbox.com/">Mapbox</a>'
      />
      <Marker position={[position.lat, position.lng]} icon={userIcon}>
        <Circle
          center={position}
          radius={160.934}
          color="blue"
          fillColor="blue"
          fillOpacity={0.1}
        />
      </Marker>
    </MapContainer>
  );
};

export default Map;

Here, we have created two react components. The first one is a handler which moves the map to the location of the user as soon as we get that. The second component is the main component, where we have a getLocation function that uses the navigator API to get the user's location and set it to the position useState.

Finally, in the return statement, we display the map using the react leaflet and then add a marker at the user's location. The Circle component creates a small blue circle around the user's location. You can change the icon configuration in the userIcon variable. I am using a location image that I added to the public folder, but feel free to use whatever you like!

💡
Make sure to update the API URL in the TileLayer component to your API URL

We are also using a Coordinates type, so create a new file utils/types/types.ts and add the following:

export type Coordinates = {
    lat: number;
    lng: number;
};

If you head over to your browser, you should be able to see a map like this:

Map being rendered on localhost

Let's now get the NFTs from the drop and place them on the map!

We'll first use the useContract and useNFTs hook to fetch the NFTs like this:

  const { contract } = useContract(
    "CONTRACT_ADDRESS"
  );
  const { data: nfts } = useNFTs(contract);

Then, inside the MapContainer map through the NFTs and display an NFTMarker component for each NFT like this:

   {nfts &&
        nfts.length > 0 &&
        nfts.map((nft) => (
          <NFTMarker key={nft.metadata.id} nft={nft} userPosition={position} />
        ))}

Let's now create the NFTMarker component. Create a new file in components named NFTMarker.tsx and add the following:

import { MediaRenderer, NFT, useAddress } from "@thirdweb-dev/react";
import { Coordinates } from "../utils/types/types";
import L from "leaflet";
import { useState } from "react";
import { haversineDistance } from "../lib/haversineDistance";
import { Marker, Popup } from "react-leaflet";

type MarkerComponentProps = {
  nft: NFT;
  userPosition: Coordinates;
};

const nftIcon = new L.Icon({
  iconUrl: "/nft-marker.png",
  iconSize: [25, 41],
  iconAnchor: [12, 41],
  popupAnchor: [1, -34],
});

const NFTMarker: React.FC<MarkerComponentProps> = ({ nft, userPosition }) => {
  const address = useAddress();
  const [isClaiming, setIsClaiming] = useState(false);
  // @ts-ignore
  const latitude = nft.metadata.attributes[0].value;
  // @ts-ignore
  const longitude = nft.metadata.attributes[1].value;
  const nftPosition: Coordinates = { lat: latitude, lng: longitude };
  const radius = 0.1;

  const isWithinRadius = () => {
    const distance = haversineDistance(userPosition, nftPosition);
    return distance <= radius;
  };

  const claimNFT = async () => {
    setIsClaiming(true);
    try {
      const response = await fetch("/api/mintNFT", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          tokenId: nft.metadata.id,
          address: address,
          userPosition: userPosition,
          nftPosition: nftPosition,
        }),
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(
          data.error || "An error occurred while claiming the NFT."
        );
      }

      alert("NFT claimed successfully!");
    } catch (error: any) {
      alert(error.message);
      console.error(error);
    } finally {
      setIsClaiming(false);
    }
  };

  return (
    <Marker position={nftPosition} icon={nftIcon}>
      <Popup>
        <div>
          <MediaRenderer src={nft.metadata.image} />
          <button disabled={!isWithinRadius() || isClaiming} onClick={claimNFT}>
            {isClaiming ? "Claiming NFT..." : "Claim NFT"}
          </button>
        </div>
      </Popup>
    </Marker>
  );
};

export default NFTMarker;

Here, we are firstly creating another icon like we did for the user's location but now for the NFT's location. Then, we create a popup that shows the NFT's image and a claim NFT button. The claim NFT button calls an API, which we have yet to create.

We are also using a haversineDistance function that calculates the distance between the user and the NFT. So, create a new file lib/haversineDistance.ts and add the following:

import { Coordinates } from "../utils/types/types";

function toRad(x: number): number {
  return (x * Math.PI) / 180;
}

export function haversineDistance(
  coords1: Coordinates,
  coords2: Coordinates,
  isMiles: boolean = false
): number {
  const R = 6371; // Radius of the Earth in km
  const dLat = toRad(coords2.lat - coords1.lat);
  const dLon = toRad(coords2.lng - coords1.lng);
  const a =
    Math.sin(dLat / 2) * Math.sin(dLat / 2) +
    Math.cos(toRad(coords1.lat)) *
      Math.cos(toRad(coords2.lat)) *
      Math.sin(dLon / 2) *
      Math.sin(dLon / 2);
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  let d = R * c;

  if (isMiles) d /= 1.60934; // Convert km to miles

  return d;
}

Let's check out the NFT markers on the map now!

💡
PRO TIP: You can simply change your location in the sensors tab under developer tools for testing purposes
Change your location using sensors under developer tools

If you now look at the map, you'll be able to see an NFT marker like this:

NFT marker on map

Finally, let's create our API, which will be responsible for claiming the NFTs to the users' wallets. Create a new file in pages/api named mintNFT.ts and add the following:

import { Engine } from "@thirdweb-dev/engine";
import { NextApiRequest, NextApiResponse } from "next";
import { NFT_CONTRACT_ADDRESS } from "../../constants/constants";
import { haversineDistance } from "../../lib/haversineDistance";

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method !== "POST") {
    return res
      .status(405)
      .json({ error: "Method not allowed, please use POST" });
  }

  const { TW_ENGINE_URL, TW_ACCESS_TOKEN, TW_BACKEND_WALLET } = process.env;

  try {
    if (!TW_ENGINE_URL || !TW_ACCESS_TOKEN || !TW_BACKEND_WALLET) {
      throw new Error("Missing environment variables");
    }

    const { tokenId, address, userPosition, nftPosition } = req.body;

    const radius = 0.1;

    const isWithinRange =
      haversineDistance(userPosition, nftPosition, true) <= radius;

    if (!isWithinRange) {
      console.log("User is not within range of the NFT");
      return res
        .status(500)
        .json({ error: "You are not within range of the NFT" });
    } else {
      const engine = new Engine({
        url: TW_ENGINE_URL,
        accessToken: TW_ACCESS_TOKEN,
      });

      const response = await engine.erc1155.claimTo(
        "mumbai",
        NFT_CONTRACT_ADDRESS,
        TW_BACKEND_WALLET,
        {
          receiver: address,
          tokenId: tokenId,
          quantity: "1",
        }
      );

      res.status(200).json(response);
    }
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: "Unexpected error." });
  }
};

export default handler;

Here, we are getting the tokenId, address, userPosition, and nftPosition from the req body. We are first getting the distance between the userPosition and the nftPosition and checking if it is less than the radius. If it is, then we use our engine SDK to claim an NFT to the user's wallet!

If you now try claiming an NFT, you will be able to see a new transaction in your engine instance, which has claimed an NFT to the wallet address you used on the website!

Conclusion

This guide taught us how to create a Web3 Pokemon Go app where you can place NFTs in different locations, and users can claim the NFTs only if they are within a certain distance from the NFT.

You learned a lot, now pat yourself on the back and share your amazing apps with us on the thirdweb discord! If you want to look at the code, check out the GitHub Repository.


Need help?

For support, join the official thirdweb Discord server or share your thoughts on our feedback board.