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
Before we begin, ensure you have the following installed:
Node.js (v16 or higher)
npm or yarn
MetaMask wallet
Git
First, let's create our project structure:
mkdir sei-monster-game
cd sei-monster-game
npm init -y
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
npx hardhat init
Choose "Create a JavaScript project" when prompted.
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,
},
};
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);
}
}
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;
}
}
npx create-next-app@latest frontend --typescript
cd frontend
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>
);
}
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);
});
});
});
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);
});
# Compile contracts
npx hardhat compile
# Run tests
npx hardhat test
# Deploy to Sei testnet
npx hardhat run scripts/deploy.js --network sei
In this tutorial, we've built a full-stack NFT monster game on the Sei blockchain. We've covered:
Setting up the development environment
Creating smart contracts for NFTs and battles
Building a frontend with Next.js
Implementing game mechanics
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