SEI ZK Series Part 9 - How to create a full stack NFT monster game on the sei blockchain

Building a Full-Stack NFT Monster Game on Sei Blockchain

Table of Contents

  1. Introduction

  2. Prerequisites

  3. Project Setup

  4. Smart Contract Development

  5. Frontend Development

  6. Testing and Deployment

  7. Conclusion

Introduction

In this tutorial, we'll build a full-stack NFT monster game on the Sei blockchain. Our game will allow users to:

  • Mint unique monster NFTs

  • Battle monsters against each other

  • Level up monsters through battles

  • Trade monsters on a marketplace

We'll use the following tech stack:

  • Smart Contracts: Solidity with Hardhat

  • Frontend: React.js with ethers.js

  • Blockchain: Sei EVM

  • IPFS: For storing monster metadata

Prerequisites

Before we begin, ensure you have the following installed:

  • Node.js (v16 or higher)

  • npm or yarn

  • MetaMask wallet

  • Git

Project Setup

1. Initialize the Project

First, let's create our project structure:

mkdir sei-monster-game
cd sei-monster-game
npm init -y

2. Install Dependencies

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox @openzeppelin/contracts dotenv
npm install @sei-io/sei-evm-sdk ethers@^5.7.2 react react-dom next @chakra-ui/react @emotion/react @emotion/styled framer-motion

3. Initialize Hardhat

npx hardhat init

Choose "Create a JavaScript project" when prompted.

4. Configure Hardhat

Create a .env file in the root directory:

PRIVATE_KEY=your_private_key_here
SEI_RPC_URL=your_sei_rpc_url
ETHERSCAN_API_KEY=your_etherscan_api_key

Update hardhat.config.js:

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
  solidity: "0.8.19",
  networks: {
    sei: {
      url: process.env.SEI_RPC_URL,
      accounts: [process.env.PRIVATE_KEY],
      chainId: 713715, // Sei testnet chain ID
    },
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};

Smart Contract Development

1. Monster NFT Contract

Create contracts/MonsterNFT.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract MonsterNFT is ERC721Enumerable, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    struct Monster {
        string name;
        uint256 level;
        uint256 experience;
        uint256 strength;
        uint256 defense;
        uint256 speed;
        string imageURI;
    }

    mapping(uint256 => Monster) public monsters;
    uint256 public mintPrice = 0.01 ether;

    event MonsterMinted(address indexed owner, uint256 indexed tokenId, string name);
    event MonsterLevelUp(uint256 indexed tokenId, uint256 newLevel);

    constructor() ERC721("MonsterNFT", "MNFT") {}

    function mintMonster(string memory _name, string memory _imageURI) public payable {
        require(msg.value >= mintPrice, "Insufficient payment");

        _tokenIds.increment();
        uint256 newTokenId = _tokenIds.current();

        // Generate random stats
        uint256 strength = uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender, newTokenId, "strength"))) % 100;
        uint256 defense = uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender, newTokenId, "defense"))) % 100;
        uint256 speed = uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender, newTokenId, "speed"))) % 100;

        monsters[newTokenId] = Monster({
            name: _name,
            level: 1,
            experience: 0,
            strength: strength,
            defense: defense,
            speed: speed,
            imageURI: _imageURI
        });

        _safeMint(msg.sender, newTokenId);
        emit MonsterMinted(msg.sender, newTokenId, _name);
    }

    function battle(uint256 _attackerId, uint256 _defenderId) public {
        require(_isApprovedOrOwner(msg.sender, _attackerId), "Not owner or approved");
        require(_exists(_defenderId), "Defender does not exist");

        Monster storage attacker = monsters[_attackerId];
        Monster storage defender = monsters[_defenderId];

        // Simple battle logic
        uint256 attackerPower = attacker.strength + attacker.speed;
        uint256 defenderPower = defender.defense + defender.speed;

        if (attackerPower > defenderPower) {
            // Attacker wins
            attacker.experience += 10;
            checkLevelUp(_attackerId);
        } else {
            // Defender wins
            defender.experience += 10;
            checkLevelUp(_defenderId);
        }
    }

    function checkLevelUp(uint256 _tokenId) internal {
        Monster storage monster = monsters[_tokenId];
        if (monster.experience >= monster.level * 100) {
            monster.level += 1;
            monster.strength += 5;
            monster.defense += 5;
            monster.speed += 5;
            emit MonsterLevelUp(_tokenId, monster.level);
        }
    }

    function getMonster(uint256 _tokenId) public view returns (
        string memory name,
        uint256 level,
        uint256 experience,
        uint256 strength,
        uint256 defense,
        uint256 speed,
        string memory imageURI
    ) {
        Monster memory monster = monsters[_tokenId];
        return (
            monster.name,
            monster.level,
            monster.experience,
            monster.strength,
            monster.defense,
            monster.speed,
            monster.imageURI
        );
    }

    function withdraw() public onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }
}

2. Battle Arena Contract

Create contracts/BattleArena.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "./MonsterNFT.sol";

contract BattleArena {
    MonsterNFT public monsterNFT;

    struct Battle {
        uint256 attackerId;
        uint256 defenderId;
        address winner;
        uint256 timestamp;
    }

    Battle[] public battles;

    event BattleCompleted(uint256 indexed battleId, address winner, uint256 attackerId, uint256 defenderId);

    constructor(address _monsterNFTAddress) {
        monsterNFT = MonsterNFT(_monsterNFTAddress);
    }

    function startBattle(uint256 _attackerId, uint256 _defenderId) public {
        require(monsterNFT.ownerOf(_attackerId) == msg.sender, "Not owner of attacker");

        monsterNFT.battle(_attackerId, _defenderId);

        Battle memory newBattle = Battle({
            attackerId: _attackerId,
            defenderId: _defenderId,
            winner: msg.sender,
            timestamp: block.timestamp
        });

        battles.push(newBattle);

        emit BattleCompleted(battles.length - 1, msg.sender, _attackerId, _defenderId);
    }

    function getBattleHistory() public view returns (Battle[] memory) {
        return battles;
    }
}

Frontend Development

1. Initialize Next.js

npx create-next-app@latest frontend --typescript
cd frontend

2. Create Components

Create components/MonsterCard.tsx:

import { Box, Image, Text, Button, VStack, HStack } from "@chakra-ui/react";
import { ethers } from "ethers";
import { MonsterNFT } from "../typechain-types";

interface MonsterCardProps {
  tokenId: number;
  monster: {
    name: string;
    level: number;
    experience: number;
    strength: number;
    defense: number;
    speed: number;
    imageURI: string;
  };
  onBattle?: (tokenId: number) => void;
}

export const MonsterCard = ({
  tokenId,
  monster,
  onBattle,
}: MonsterCardProps) => {
  return (
    <Box
      borderWidth="1px"
      borderRadius="lg"
      overflow="hidden"
      p={4}
      maxW="sm"
      bg="white"
      boxShadow="md"
    >
      <Image src={monster.imageURI} alt={monster.name} />
      <VStack align="start" mt={4} spacing={2}>
        <Text fontSize="xl" fontWeight="bold">
          {monster.name}
        </Text>
        <Text>Level: {monster.level}</Text>
        <Text>Experience: {monster.experience}</Text>
        <HStack spacing={4}>
          <Text>Strength: {monster.strength}</Text>
          <Text>Defense: {monster.defense}</Text>
          <Text>Speed: {monster.speed}</Text>
        </HStack>
        {onBattle && (
          <Button
            colorScheme="blue"
            onClick={() => onBattle(tokenId)}
            width="full"
          >
            Battle
          </Button>
        )}
      </VStack>
    </Box>
  );
};

Create pages/index.tsx:

import { useState, useEffect } from "react";
import {
  Box,
  Container,
  Grid,
  Heading,
  Button,
  useToast,
  Input,
  VStack,
} from "@chakra-ui/react";
import { ethers } from "ethers";
import { MonsterCard } from "../components/MonsterCard";
import { MonsterNFT__factory } from "../typechain-types";

const MonsterNFT_ADDRESS = "YOUR_DEPLOYED_CONTRACT_ADDRESS";

export default function Home() {
  const [account, setAccount] = useState<string>("");
  const [monsters, setMonsters] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);
  const toast = useToast();

  const connectWallet = async () => {
    if (typeof window.ethereum !== "undefined") {
      try {
        const accounts = await window.ethereum.request({
          method: "eth_requestAccounts",
        });
        setAccount(accounts[0]);
      } catch (error) {
        console.error("Error connecting wallet:", error);
      }
    }
  };

  const mintMonster = async () => {
    if (!account) return;

    try {
      const provider = new ethers.providers.Web3Provider(window.ethereum);
      const signer = provider.getSigner();
      const contract = MonsterNFT__factory.connect(MonsterNFT_ADDRESS, signer);

      const tx = await contract.mintMonster(
        "New Monster",
        "https://your-ipfs-gateway/monster1.png",
        { value: ethers.utils.parseEther("0.01") }
      );

      await tx.wait();
      toast({
        title: "Monster Minted!",
        status: "success",
        duration: 5000,
      });

      loadMonsters();
    } catch (error) {
      console.error("Error minting monster:", error);
      toast({
        title: "Error minting monster",
        status: "error",
        duration: 5000,
      });
    }
  };

  const loadMonsters = async () => {
    if (!account) return;

    try {
      const provider = new ethers.providers.Web3Provider(window.ethereum);
      const contract = MonsterNFT__factory.connect(
        MonsterNFT_ADDRESS,
        provider
      );

      const balance = await contract.balanceOf(account);
      const monsterPromises = [];

      for (let i = 0; i < balance.toNumber(); i++) {
        const tokenId = await contract.tokenOfOwnerByIndex(account, i);
        monsterPromises.push(contract.getMonster(tokenId));
      }

      const monsterData = await Promise.all(monsterPromises);
      setMonsters(monsterData);
    } catch (error) {
      console.error("Error loading monsters:", error);
    }
  };

  useEffect(() => {
    if (account) {
      loadMonsters();
    }
  }, [account]);

  return (
    <Container maxW="container.xl" py={8}>
      <VStack spacing={8}>
        <Heading>Monster NFT Game</Heading>

        {!account ? (
          <Button onClick={connectWallet} colorScheme="blue">
            Connect Wallet
          </Button>
        ) : (
          <>
            <Button onClick={mintMonster} colorScheme="green">
              Mint New Monster
            </Button>

            <Grid
              templateColumns="repeat(auto-fill, minmax(250px, 1fr))"
              gap={6}
            >
              {monsters.map((monster, index) => (
                <MonsterCard
                  key={index}
                  tokenId={index}
                  monster={monster}
                  onBattle={(tokenId) => {
                    // Implement battle logic
                  }}
                />
              ))}
            </Grid>
          </>
        )}
      </VStack>
    </Container>
  );
}

Testing and Deployment

1. Write Tests

Create test/MonsterNFT.test.js:

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("MonsterNFT", function () {
  let MonsterNFT;
  let monsterNFT;
  let owner;
  let addr1;
  let addr2;

  beforeEach(async function () {
    [owner, addr1, addr2] = await ethers.getSigners();
    MonsterNFT = await ethers.getContractFactory("MonsterNFT");
    monsterNFT = await MonsterNFT.deploy();
    await monsterNFT.deployed();
  });

  describe("Minting", function () {
    it("Should mint a new monster", async function () {
      const mintPrice = ethers.utils.parseEther("0.01");

      await expect(
        monsterNFT
          .connect(addr1)
          .mintMonster("Test Monster", "ipfs://test", { value: mintPrice })
      )
        .to.emit(monsterNFT, "MonsterMinted")
        .withArgs(addr1.address, 1, "Test Monster");

      const monster = await monsterNFT.getMonster(1);
      expect(monster.name).to.equal("Test Monster");
    });
  });

  describe("Battles", function () {
    it("Should allow monsters to battle", async function () {
      const mintPrice = ethers.utils.parseEther("0.01");

      // Mint two monsters
      await monsterNFT
        .connect(addr1)
        .mintMonster("Attacker", "ipfs://attacker", { value: mintPrice });

      await monsterNFT
        .connect(addr2)
        .mintMonster("Defender", "ipfs://defender", { value: mintPrice });

      // Battle
      await monsterNFT.connect(addr1).battle(1, 2);

      const monster1 = await monsterNFT.getMonster(1);
      expect(monster1.experience).to.be.gt(0);
    });
  });
});

2. Deploy Script

Create scripts/deploy.js:

const hre = require("hardhat");

async function main() {
  const MonsterNFT = await hre.ethers.getContractFactory("MonsterNFT");
  const monsterNFT = await MonsterNFT.deploy();
  await monsterNFT.deployed();

  console.log("MonsterNFT deployed to:", monsterNFT.address);

  const BattleArena = await hre.ethers.getContractFactory("BattleArena");
  const battleArena = await BattleArena.deploy(monsterNFT.address);
  await battleArena.deployed();

  console.log("BattleArena deployed to:", battleArena.address);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

3. Deployment Commands

# Compile contracts
npx hardhat compile

# Run tests
npx hardhat test

# Deploy to Sei testnet
npx hardhat run scripts/deploy.js --network sei

Conclusion

In this tutorial, we've built a full-stack NFT monster game on the Sei blockchain. We've covered:

  1. Setting up the development environment

  2. Creating smart contracts for NFTs and battles

  3. Building a frontend with Next.js

  4. Implementing game mechanics

  5. Testing and deployment

To extend this game, you could:

  • Add more complex battle mechanics

  • Implement a marketplace for trading monsters

  • Add special abilities and items

  • Create a breeding system

  • Implement a tournament system

Additional Resources

Subscribe to 4undRaiser
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.