In this weeks lesson we were taught how to create a dynamic NFT with metadata stored on chain. With a dynamic NFT we are able to dynamically change the image that displays based on on-chain activities, and by storing the metadata on-chain we do not rely on any outside data providers like IPFS.

At a high level, we are going to make a NFT displaying a Name and Level that could be used in an RPG game. The level can be increased by the NFT holder by “training” their character, or more technically speaking, submitting a transaction to the NFT contract “train” function.

Chain Battles NFT

To accomplish this task we again used Hardhat.

As with our lesson in Week 2, we first started by setting up our project folder. I created an empty folder, then began installing libraries and environments. Here are all the commands I used for set-up.

//install hardhat
npm add hardhat
//initiate project
npx hardhat init
//install dotenv used to hide our secrets
npm install dotenv
//install basic openzeppelin contracts
npm add @openzeppelin/contracts
//install javascript libraries
npm install @nomiclabs/hardhat-waffle
//needed for npx hardhat test
npm add @nomicfoundation/hardhat-toolbox

Before getting into the details of the code it is important to understand at a high level what we are trying to accomplish.

When I mint an NFT, the tokenURI is a URL that returns what image is supposed to appear. In week 1, we used a URL to an image stored on IPFS. This week we are going to use a URL that resolves to an image that is not stored anywhere. SVG (Structured Vector Graphics) allow us to describe what should appear without actually including any of the images themselves.

If you are not following, copy and paste the URL below into a browser. An image will appear.

Resulting image

The incredibly long string of random numbers and letters (IDxzdmcgeG1sbn…..) is actually the SVG definition encoded to a base64 string. If you take the string and decode it, you can see the SVG definition in a more understandable format.

See how the encoded base64 string defines the SVG image when it is decoded

See where the getLevels() function in the SVG definition in the screenshot above? That is where we will be changing the SVG definition so it can be changed when the underlying state our our contract changes. If getLevels returns a 2 then my image will say Levels : 2.

Now that we understand what we are trying to build, let’s get into the code.

Here are the functions that we need to build;

  • mint: to mint — of course. Calls getTokenURI.
  • getTokenURI: to get the TokenURI of an NFT. Calls generateCharacter
  • generateCharacter: to generate and update the SVG image of our NFT. Calls getLevels.
  • getLevels: to get the current level of an NFT
  • train: to train an NFT and raise its level

The mint function is pretty straightforward. We increment and call _safemint as we have done before.

Next we must store the current level of 0 for the tokenID we just created. For this we use a mapping table called tokenIdToLevels which is defined in the contract.

Then for _setTokenURI we must call a new function, getTokenURI, that will generate our image using the current Level of 0

//The mapping will link an uint256, the NFTId, to another uint256, the level of the NFT
mapping(uint256 => uint256) public tokenIdToLevels;

function mint() public {
// we first increment the value of our _tokenIds variable, and store its current value on a new uint256 variable, in this case, "newItemId".
uint256 newItemId = _tokenIds.current();
// _safeMint() function from the OpenZeppelin ERC721 library, passing the msg.sender variable, and the current id.
_safeMint(msg.sender, newItemId);

// create a new item in the tokenIdToLevels mapping and assign its value to 0
tokenIdToLevels[newItemId] = 0;

// we set the token URI passing the newItemId and the return value of getTokenURI()
_setTokenURI(newItemId, getTokenURI(newItemId));

The getTokenURI function on the very last line above, will return an encoded JSON string that contains all the metadata for our NFT.

First we create a variable called dataURI which is the beginning of our JSON string. When we define the image element (“image”:), we call another function, generateCharacter, which will be our SVG definition.

Finally at the end, we set our URL prefix and append our encoded JSON string into base64.

    function getTokenURI(uint256 tokenId) public returns (string memory){
//create variable with NFT metadata
bytes memory dataURI = abi.encodePacked(
'"name": "Chain Battles #', tokenId.toString(), '",',
'"description": "Battles on chain",',
'"image": "', generateCharacter(tokenId), '"', //Call SVG definition builder
//return final URL
return string(

The generateCharacter function must return an encoded version of our SVG graphic, similar to what we displayed above. In the first step, we set a variable svg equal to the SVG string, and wrapped in an abi.encoded function. In the area where the Level will appear, we call the getLevels function and pass the tokenId as a parameter.

Finally at the end, we return our URL prefix and append our encoded SVG string(xml based) into base64 and encode the entire string again.

function generateCharacter(uint256 tokenId) public returns(string memory){

bytes memory svg = abi.encodePacked(
'<svg xmlns="" preserveAspectRatio="xMinYMin meet" viewBox="0 0 350 350">',
'<style>.base { fill: white; font-family: serif; font-size: 14px; }</style>',
'<rect width="100%" height="100%" fill="black" />',
'<text x="50%" y="40%" class="base" dominant-baseline="middle" text-anchor="middle">',"Warrior",'</text>',
'<text x="50%" y="50%" class="base" dominant-baseline="middle" text-anchor="middle">', "Levels: ",getLevels(tokenId),'</text>',

return string(

The getLevels function simply calls the tokenIDtoLevels mapping table and returns the result defined by the variable levels. The toString method converts the levels uint256 value to a string that can be used in our SVG definition.

    function getLevels(uint256 tokenId) public view returns (string memory) {
uint256 levels = tokenIdToLevels[tokenId];

// the toString() function, that's coming from the OpenZeppelin Strings library, and transforms our level, that is an uint256, into a string - that will be then be used by generateCharacter function as we've seen before.
return levels.toString();

The above code is enough for us to mint new characters and display the related SVG image as the NFT. The final step that we need to build is a function that changes what is returned from getLevels. This is what will make the NFT dynamic.

The train function accepts the tokenID as a parameter, checks the current level, increments it by one, and then saves the new level to the state of the contract. Before allowing any of this to happen we first check/require that the tokenID that is passed actually exists, and finally that the owner of the NFT is the one doing the training.

    function train(uint256 tokenId) public {
require(_exists(tokenId), "Please use an existing token");
require(ownerOf(tokenId) == msg.sender, "You must own this token to train it");

uint256 currentLevel = tokenIdToLevels[tokenId];
tokenIdToLevels[tokenId] = currentLevel + 1;
_setTokenURI(tokenId, getTokenURI(tokenId));

That completes the contract for our dynamic and fully on-chain NFT 🎉. Now it is time to deploy it to testnet.

The process of deploying the contract was very similar to the steps taken in Week 2. We created a deploy.js script and stored our “secrets” using a .env file. I will not cover this portion in detail since I have already covered in the last weeks post.

After deploying our contract to the Polygon Mumbai test network, we viewed the contract on polygonscan. Our next step was to validate that both our mint and train functions were working as designed.

It is possible to interact with the contract via polygonscan, but we needed to verify the contract code first.

To verify the contract code, we passed the hardhat verify command in terminal.

npx hardhat verify --network mumbai 0xD638d9783C8896F2A8cfB0726C8F192010aa53b9

After the successful completion of this command, we refreshed polygonscan and a green checkmark appeared next to the Contract tab. We are now verified ✅. Let’s start interacting with the contract.

Upon selecting the Contract tab, we can see our contract Code, but also buttons labeled Read/Write Contract. With the Write Contract button highlighted we get the ability to Connect to Web3 and then to run the mint function via the Write button which initiates a Metamask transaction.

After our transaction confirms we can head to to see our beauty. Completely on-chain, no IPFS needed.

Now comes the dynamic part. We head back to polygonscan and under the same screen where we ran our mint function, we find our train function. In the case of the train function we must pass the tokenID as a parameter. Since we are working with token #1, we pass the number 1 and select Write.

After confirmation of this transaction, we can go back to and confirm that we have officially leveled up! 🎉🎉

That completes the lesson for this week. Alchemy docs can be found here.

As with last week we were left with a challenge. Here is the challenge I will cover in my next post

Week 3 challenge

At the moment we’re only storing the level of our NFTs, why not store more?

Substitute the current tokenIdToLevels[] mapping with a struct that stores:

  • Level
  • Speed
  • Strength
  • Life