Skip to content

Instantly share code, notes, and snippets.

@viral-sangani
Created January 13, 2022 12:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save viral-sangani/5650463470dd23dfcb2f9cff382173b1 to your computer and use it in GitHub Desktop.
Save viral-sangani/5650463470dd23dfcb2f9cff382173b1 to your computer and use it in GitHub Desktop.
NFTEpicGame Contract Details
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./libraries/Base64.sol";
import "hardhat/console.sol";

Here we are importing all the necessary openzeppelin contract. We need ERC721 to mint character NFTs and IERC20 to call ERC20 methods on our EPIC token. We will be using some utility contracts like Counters.sol, SafeMath.sol, Strings.sol, Ownable.sol, ReentrancyGuard.sol and Base64.sol.

console.sol is provided by hardhat, used to add some debug statements in the smart contract which will be removed when the contract is compiled for deployment.

contract NFTEpicGame is ERC721, ReentrancyGuard, Ownable {
    using Counters for Counters.Counter;
    using SafeMath for uint256;

    Counters.Counter private _tokenIds;
    address epicToken;
    uint256 regenTime = 60;
}

We will inherit from ERC721, ReentrancyGuard, and Ownable contract in our NFTEpicGame. ERC721 is used to mint NFT characters for our users. ReentrancyGuard is a modifier that can prevent reentrancy during certain functions (read more about ReentrancyGuard here). Ownable provides a basic access control mechanism where an owner account can be granted exclusive access to specific functions.

_tokenIds is used to keep track of all NFTs, epicToken is the contract address of our ERC20 token, and regenTime is the time in seconds user have to wait to reclaim the health (briefly explained below).

    struct CharacterAttributes {
        uint256 characterIndex;
        string name;
        string imageURI;
        uint256 hp;
        uint256 maxHp;
        uint256[] attacks;
        uint256[] specialAttacks;
        uint256 lastRegenTime;
    }

    struct AttackType {
        uint256 attackIndex;
        string attackName;
        uint256 attackDamage;
        string attackImage;
    }

    struct SpecialAttackType {
        uint256 price;
        uint256 specialAttackIndex;
        string specialAttackName;
        uint256 specialAttackDamage;
        string specialAttackImage;
    }

    AttackType[] allAttacks;
    SpecialAttackType[] allSpecialAttacks;

    struct BigBoss {
        string name;
        string imageURI;
        uint256 hp;
        uint256 maxHp;
        uint256 attackDamage;
    }

    BigBoss public bigBoss;

    CharacterAttributes[] defaultCharacters;

    mapping(uint256 => CharacterAttributes) public nftHolderAttributes;
    mapping(address => uint256) public nftHolders;
  • CharacterAttributes struct is used to store all the character attributes of a player. An instance of CharacterAttributes is created when the user mints the character for the first time.
  • CharacterAttributes also has lastRegenTime which stores that last time player has requested to regenerate the health for their character.
  • AttackType stores the data related to each attack. It includes attack name, attack damage, and attack image displayed in the game.
  • SpecialAttackType is similar to AttackType but has one extra attribute i.e. - price. Since players have to buy special attacks from the marketplace, each special attack has a price marked.
  • allAttacks and allSpecialAttacks keeps the track of all the attacks and special attacks in the game. Keeping these in a map helps in reducing contract size as we will not store all the data associated with an attack multiple times for each character but we will store only the attack index which in effect reduces the contract transaction costs.
  • BigBoss struct contains all the data for the boss character.
  • defaultCharacters array contains the array of all the mintable characters that the player can choose from.
  • nftHolders is a map that holds the token ID for each address/player.
  • nftHolderAttributes holds the CharacterAttributes struct for each token ID minted by the players.
    event CharacterNFTMinted(
        address sender,
        uint256 tokenId,
        uint256 characterIndex
    );
    event AttackComplete(uint256 newBossHp, uint256 newPlayerHp);
    event RegenCompleted(uint256 newHp);

CharacterNFTMinted is an event that is emitted when a new character is minted by the player. AttackComplete is emitted when a player performs a normal attack or a special attack and RegenCompleted is emitted when a user has successfully called the claimHealth method.

    constructor(
        string[] memory characterName,
        string[] memory characterImageURI,
        uint256[] memory characterMaxHp,
        uint256[][] memory characterAttacks,
        string memory bossName,
        string memory bossImageURI,
        uint256 bossHp,
        uint256 bossAttackDamage,
        address epicTokenAddress
    ) ERC721("Heroes", "HERO") {
        epicToken = epicTokenAddress;
        for (uint256 i = 0; i < characterName.length; i++) {
            CharacterAttributes memory charAttribute;
            charAttribute.characterIndex = i;
            charAttribute.name = characterName[i];
            charAttribute.imageURI = characterImageURI[i];
            charAttribute.hp = characterMaxHp[i];
            charAttribute.maxHp = characterMaxHp[i];
            charAttribute.attacks = characterAttacks[i];
            defaultCharacters.push(charAttribute);
        }
        _tokenIds.increment();
        bigBoss = BigBoss({
            name: bossName,
            imageURI: bossImageURI,
            hp: bossHp,
            maxHp: bossHp,
            attackDamage: bossAttackDamage
        });
    }
  • In the constructor we are accepting arrays of characterName, characterImageURI, characterMaxHp and characterAttacks.
  • We will loop over these arrays and create CharacterAttributes instances and add them to the defaultCharacters array.
  • Note that the size/length of all these arrays should be the same.
  • We also accepts boss attributes like bossName, bossImageURI, bossHp, bossAttackDamage and create an instance of BigBoss struct.
  • We increment the _tokenIds so that it starts from 1.
  • At last, we need to accept the EPIC token address so that we can accept that token when the user mints the NFT or made any purchase from the marketplace.
  • Since we are accepting the EPIC token address in the NFTEpicGame contract, we have to deploy the token first and then use that address to deploy NFTEpicGame.
    function addAttacks(
        // All the attacks for each character
        string[] memory attackNames,
        string[] memory attackImages,
        uint256[] memory attackDamages,
        uint256[] memory attackIndexes
    ) public onlyOwner {
        for (uint256 j = 0; j < attackIndexes.length; j++) {
            allAttacks.push(
                AttackType(
                    attackIndexes[j],
                    attackNames[j],
                    attackDamages[j],
                    attackImages[j]
                )
            );
        }
    }

    function addSpecialAttacks(
        // All the special attacks for each character
        string[] memory specialAttackNames,
        string[] memory specialAttackImages,
        uint256[] memory specialAttackDamages,
        uint256[] memory specialAttackPrices,
        uint256[] memory specialAttackIndexes
    ) public onlyOwner {
        for (uint256 j = 0; j < specialAttackIndexes.length; j++) {
            allSpecialAttacks.push(
                SpecialAttackType(
                    specialAttackPrices[j],
                    specialAttackIndexes[j],
                    specialAttackNames[j],
                    specialAttackDamages[j],
                    specialAttackImages[j]
                )
            );
        }
    }
  • Both addAttacks and addSpecialAttacks are used to add attacks and special attacks to the game.
  • Note that both these methods have an onlyOwner modifier, which means only the deploying address can call these methods and add new attacks and special attacks.
  • Both these methods accept arrays of data like name, image, damage, indexes, and loop over them to create respective struct instances and add them to the allAttacks array or the allSpecialAttacks array.
    function mintCharacterNFT(uint256 _characterIndex) external payable {
        require(
            _characterIndex < defaultCharacters.length,
            "Character index out of bounds"
        );
        require(
            IERC20(epicToken).allowance(msg.sender, address(this)) >= 10 ether,
            "Please approve the required token transfer before minting"
        );
        IERC20(epicToken).transferFrom(msg.sender, address(this), 10 ether);
        uint256 newItemId = _tokenIds.current();
        _safeMint(msg.sender, newItemId);
        nftHolderAttributes[newItemId] = CharacterAttributes({
            characterIndex: _characterIndex,
            name: defaultCharacters[_characterIndex].name,
            imageURI: defaultCharacters[_characterIndex].imageURI,
            hp: defaultCharacters[_characterIndex].hp,
            maxHp: defaultCharacters[_characterIndex].maxHp,
            attacks: defaultCharacters[_characterIndex].attacks,
            specialAttacks: defaultCharacters[_characterIndex].specialAttacks,
            lastRegenTime: block.timestamp
        });
        nftHolders[msg.sender] = newItemId;
        _tokenIds.increment();
        emit CharacterNFTMinted(msg.sender, newItemId, _characterIndex);
    }

mintCharacterNFT is called by all the players once they join the game. In mintCharacterNFT we have defined that to mint a new character user has to pay 10 EPIC tokens. This is what happens in the mintCharacterNFT method -

  • mintCharacterNFT accepts the character index that the player wants to mint and makes sure that the index is not out of bounds.
  • Next, it checks the allowance of epicToken is greater than or equal to 10 EPIC tokens, if not methods revert with an error message.
  • If the contract has allowance then we call the transferFrom method of ERC20 token and transfer 10 EPIC tokens from the player's account to the contract address.
  • Once all the checks are completed, we get the current token ID and call the _safeMint method of the ERC721 contract with msg.sender and token Id. This call will essentially mint a new NFT for the player.
  • After minting a new NFT character we have to create a new CharacterAttributes instance and use the defaultCharacters to fetch the metadata of that player.
  • Now that we have a new instance of CharacterAttributes we have to add it to nftHolderAttributes corresponding to the current token ID.
  • Finally, we can map the player's address to the token ID in the nftHolders map and increment the _tokenIds.
    function claimHealth() external {
        require(
            nftHolders[msg.sender] != 0,
            "You don't have a character to claim health"
        );
        require(
            IERC20(epicToken).allowance(msg.sender, address(this)) >= 0.1 ether,
            "Please approve the required token transfer before minting"
        );
        IERC20(epicToken).transferFrom(msg.sender, address(this), 0.1 ether);
        uint256 tokenId = nftHolders[msg.sender];
        CharacterAttributes memory character = nftHolderAttributes[tokenId];
        uint256 currentTime = block.timestamp;
        uint256 timeSinceLastRegen = currentTime - character.lastRegenTime;

        if (timeSinceLastRegen > regenTime) {
            uint256 newHp = character.hp + timeSinceLastRegen.div(60);
            if (newHp > character.maxHp) {
                newHp = character.maxHp;
            }
            character.hp = newHp;
            character.lastRegenTime = currentTime;
            nftHolderAttributes[tokenId] = character;
            emit RegenCompleted(newHp);
        }
    }

claimHealth can be called by any character holder to gain some health for their player. claimHealth provides 1 HP for each minute since the player has called the claimHealth method. This means if the user has last called the claimHealth method after 20 minutes, then the player's character will have 20 new HP points. Note that the addition of these new HP points cannot go more than the maxHp of that character.

Each time a player wants to call the claimHealth method they have to pay a fee of 0.1 EPIC tokens.

  • In claimHealth first, we make sure that the calling player has a character minted and the player has approved the 0.1 EPIC token for the contract.
  • We have to call the transferFrom method of the ERC20 token to transfer 0.1 EPIC tokens from the player's wallet to the contract address. Once the tokens are transferred we have to retrieve the tokenId of the player from the nftHolders map.
  • Using tokenId we can fetch the character struct from nftHolderAttributes map. character has lastRegenTime which can be used to calculate the time elapsed since the player has called the claimHealth method.
  • If it's been more than regenTime which is 60 seconds in our case, we allow them to claim the health.
  • To calculate the new HP for the character we have to divide timeSinceLastRegen by 60 since timeSinceLastRegen is the seconds elapsed since the player has claimed the health and add that number to the current HP of the player.
  • If the newHp is more than the maxHp of that player we give maxHp to that character. At the end we have to update the lastRegenTime to currentTime and update nftHolderAttributes with current character.
    function attackBoss(uint256 attackIndex) public {
        uint256 nftTokenIdOfPlayer = nftHolders[msg.sender];
        CharacterAttributes storage player = nftHolderAttributes[
            nftTokenIdOfPlayer
        ];
        require(player.hp > 0, "Error: character must have HP to attack boss.");
        require(bigBoss.hp > 0, "Error: boss must have HP to attack boss.");
        uint256 attackDamage = 0;
        for (uint256 i = 0; i < player.attacks.length; i++) {
            if (attackIndex == player.attacks[i]) {
                attackDamage = allAttacks[attackIndex].attackDamage;
            }
        }
        require(attackDamage > 0, "Error: attack must have damage.");
        if (bigBoss.hp < attackDamage) {
            bigBoss.hp = 0;
        } else {
            bigBoss.hp = bigBoss.hp - attackDamage;
        }

        if (player.hp < bigBoss.attackDamage) {
            player.hp = 0;
        } else {
            player.hp = player.hp - bigBoss.attackDamage;
        }
        emit AttackComplete(bigBoss.hp, player.hp);
    }

attackBoss is called when a player uses any normal attack on the boss. Any player can only attack the boss if the player and boss both have more than 0 HP points.

  • First, we retrieve the current character instance of nftHolderAttributes and check all the requirements.
  • attackBoss accepts the attackIndex which corresponds to the attack player wants to perform on the boss.
  • To get the attackDamage from attackIndex we have to loop over all the available attacks of that character and make sure that the player has called the method with the correct attackIndex.
  • Once we have attackDamage we have to make sure that attackDamage is more than 0 or else revert the method call.
  • The only condition attackDamage can be 0 is when attackIndex is not present in allAttacks.
  • In the end, reduce attackDamage from the boss's HP and reduce the boss's attack damage from the character's HP.

attackSpecialBoss is similar to attackBoss. Only difference is instead of fetching attackDamage from allAttacks, we fetch it from allSpecialAttacks

    function buySpecialAttack(uint256 specialAttackIndex) public payable {
        uint256 nftTokenIdOfPlayer = nftHolders[msg.sender];
        require(
            nftTokenIdOfPlayer > 0,
            "Error: must have NFT to buy special attack."
        );

        CharacterAttributes storage player = nftHolderAttributes[
            nftTokenIdOfPlayer
        ];
        require(
            IERC20(epicToken).allowance(msg.sender, address(this)) >=
                allSpecialAttacks[specialAttackIndex].price,
            "Error: user must provide enough token to buy special attack."
        );
        IERC20(epicToken).transferFrom(
            msg.sender,
            address(this),
            allSpecialAttacks[specialAttackIndex].price
        );
        player.specialAttacks.push(specialAttackIndex);
        emit AttackComplete(bigBoss.hp, player.hp);
    }

buySpecialAttack is the method called from the marketplace, where the player can buy and special attack for their character. Each special attack has a price associated with it in EPIC tokens and the player has to approve that price to purchase the special attack.

Once we confirm that the user has approved the special attack's price we make a transferFrom call to transfer the token and push the special attack index in the specialAttacks array of the player's character.

    function checkIfUserHasNFT()
        public
        view
        returns (CharacterAttributes memory)
    {
        uint256 userNftTokenId = nftHolders[msg.sender];
        if (userNftTokenId > 0) {
            return nftHolderAttributes[userNftTokenId];
        } else {
            CharacterAttributes memory emptyStruct;
            return emptyStruct;
        }
    }

    function getAllDefaultCharacters()
        public
        view
        returns (CharacterAttributes[] memory)
    {
        return defaultCharacters;
    }

    function getAllAttacks() public view returns (AttackType[] memory) {
        return allAttacks;
    }

    function getAllSpecialAttacks()
        public
        view
        returns (SpecialAttackType[] memory)
    {
        return allSpecialAttacks;
    }

    function getBigBoss() public view returns (BigBoss memory) {
        return bigBoss;
    }

These all are the helper function to read the data from the contract. checkIfUserHasNFT check if the player has minted NFTbefore, if yes then it returns the CharacterAttributes instance of the minted NFT or else returns an empty instance of CharacterAttributes. getAllDefaultCharacters method returns an array of all the available characters that the user can mint. getAllAttacks and getAllSpecialAttacks returns arrays of allAttacks and allSpecialAttacks respectively. getBigBoss simply returns the bigBoss variable.

function tokenURI(uint256 _tokenId)
        public
        view
        override
        returns (string memory)
    {
        CharacterAttributes memory charAttributes = nftHolderAttributes[
            _tokenId
        ];
        string memory strHp = Strings.toString(charAttributes.hp);
        string memory strMaxHp = Strings.toString(charAttributes.maxHp);

        string memory specialAttacksStr = "";
        string memory attacksStr = "";

        for (uint256 i = 0; i < charAttributes.specialAttacks.length; i++) {
            uint256 index = charAttributes.specialAttacks[i];
            specialAttacksStr = string(
                abi.encodePacked(
                    specialAttacksStr,
                    ', {"trait_type": "Special Attack - ',
                    allSpecialAttacks[index].specialAttackName,
                    '", "value": ',
                    Strings.toString(
                        allSpecialAttacks[index].specialAttackDamage
                    ),
                    "}"
                )
            );
        }

        for (uint256 i = 0; i < charAttributes.attacks.length; i++) {
            uint256 index = charAttributes.attacks[i];
            attacksStr = string(
                abi.encodePacked(
                    attacksStr,
                    ', {"trait_type": "',
                    allAttacks[index].attackName,
                    '", "value": ',
                    Strings.toString(allAttacks[index].attackDamage),
                    "}"
                )
            );
        }

        string memory json = Base64.encode(
            bytes(
                string(
                    abi.encodePacked(
                        '{"name": "',
                        charAttributes.name,
                        " -- NFT #: ",
                        Strings.toString(_tokenId),
                        '", "description": "This is an NFT that lets people play in the Epic NFT Game!", "image": "',
                        charAttributes.imageURI,
                        '", "attributes": [{"trait_type": "Health Points", "value": ',
                        strHp,
                        ', "max_value": ',
                        strMaxHp,
                        "}",
                        specialAttacksStr,
                        attacksStr,
                        "]}"
                    )
                )
            )
        );

        string memory output = string(
            abi.encodePacked("data:application/json;base64,", json)
        );
        return output;
    }

The tokenURI on an NFT is a unique identifier of what the token "looks" like. A URI could be an API call over HTTPS, an IPFS hash, or anything else unique. tokenURI will return something like this -

{
  "name": "Spider Man -- NFT #: 1",
  "description": "This is an NFT that lets people play in the Epic NFT Game!",
  "image": "https://cdezqunbfyr4dwr4jcbtgjvgnzzo32xau4lkw52jkiz73phudu7a.arweave.net/EMmYUaEuI8HaPEiDMyambnLt6uCnFqt3SVIz_bz0HT4",
  "attributes": [
    {
      "trait_type": "Health Points",
      "value": 300,
      "max_value": 300
    },
    {
      "trait_type": "Special Attack - Bomb Attack",
      "value": 80
    },
    {
      "trait_type": "Special Attack - Explosion Attack",
      "value": 100
    },
    {
      "trait_type": "Punch",
      "value": 50
    },
    {
      "trait_type": "Spider Attack",
      "value": 55
    },
    {
      "trait_type": "Web Shooter",
      "value": 65
    }
  ]
}

The JSON show what an NFT looks like and its attributes. The image section points to a URI of what the NFT looks like. In our case, we will be using Arweave to store all the images and use the URI provided by Arweave. This makes it easy for NFT marketplace platforms like Opensea, Rarible, and Mintable to render NFTs on their platform and show all the attributes of those NFTs since they are all looking for this metadata.

In this function, we do string manipulation magic to create a JSON string and then convert it to Base64 and attach data:application/json;base64, at the from so that our browser knows how to handle the base64 string. Note that this format is recommended by big NFT marketplaces to render the NFT with all the metadata.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment