How To Create an NFT Game
In this tutorial, we’ll be building an NFT-based Web3 “game.” By “game,” I mean a Tamagotchi-like dApp that is 100% stored on the blockchain.
We will store all assets and everything regarding this dApp on the blockchain. Let’s take a look at what we’ll be building. The web app portion of the dApp is pretty simple. It’s going to give us access to our EmojiGotchi.
Starter Project Files
You can find the starter for this project here.
It’s broken into three different branches. To start, we’ll be in the main branch. This is the best place to start. The repo is split into three different branches, and once we’ve built the contract out, the code should match the foundry branch. Once you build out the complete application, it should be the same as the final branch. This branch houses the completed application.
We are going to be using Foundry. Foundry is a new toolkit for Ethereum development. Foundry is fast, and the tests are written in Solidity. Not having to switch context between Javascript and Solidity when writing tests makes for a good user experience, and the contract code is excellent!
You can find the installation instruction for Foundry in the documentation.
You will also be using SvelteKit for the frontend. I am a fan of SvelteKit as it’s easy to explain what’s going on. There’s a little bit of “Svelte magic” that you’ll get into, but overall it’s pretty straightforward as a place to start. Let’s dive in.
The project is set up as a mono repo. There are two subfolders, foundry
and svelte
, and a bit of VS Code Magic if you choose to use that editor. The workbench title bar will be colored based on which portion of the project you are in.
When comparing the base install of Foundry and SvelteKit, there are a couple of additional tools I’ve set up.
deploy.sh
#!/usr/bin/env bash
# Read the Amoy RPC URL
echo Enter Your Amoy RPC URL:
echo Example: "https://polygon-amoy.g.alchemy.com/v2/XXXXXXXXXX"
read -s rpc
# Read the contract name
echo Which contract do you want to deploy \(eg Greeter\)?
read contract
forge create ./src/${contract}.sol:${contract} -i --rpc-url $rpc
This will let you deploy our contract to Amoy, and all this is doing is reading variables without displaying what you’re typing in on the command line. This will ensure your private keys aren’t stored in your command line history.
remappings.txt
@openzeppelin/=lib/openzeppelin-contracts/
ds-test/=lib/ds-test/src/
This remapping file lets you use things like OpenZeppelin contracts and import them in the way you would typically use other tools such as Hardhat or Remix. This file remaps the import to the directory where they are housed. I’ve also installed the OpenZeppelin contracts via forge install openzeppelin/openzeppelin-contracts
These will be used to create the ERC-721 contract.
Start Building The dApp
EmojiGotchi Screenshot
Let’s take another look at EmojiGotchi. There are a few things to think about here. First, the image is an SVG that is stored on-chain. You’ve got a few different values to track: hunger, enrichment, and happiness. You’ve got two different things you can do: You can feed it and play with it. Let’s stub out some tests. If you look within the Foundry directory, you have your src
directory, and inside src
, you have a couple of things.
Foundry provides us with a basic contract and test. This enables us to run forge test
.
Forge is one of the commands within Foundry. When we run forge test
, it compiles our contract and it runs our test, and you can see that our tests passed.
❯ foundry (main) ✘ forge test
[⠒] Compiling...
No files changed, compilation skipped
Running 1 test for src/test/Contract.t.sol:ContractTest
[PASS] testExample() (gas: 190)
Test result: ok. 1 passed; 0 failed; finished in 222.21µs
❯ foundry (main) ✘
This also shows that you have installed Foundry correctly, and you can continue to build out the contract.
Let’s go ahead and add a new file, EmojiGotchi.t.sol
. The t shows that it’s a test.
src/test/EmojiGotchi.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;
import "../EmojiGotchi.sol";
import "ds-test/test.sol";
contract EmojiGotchiTest is DSTest {
EmojiGotchi public eg;
function setUp() public {}
function testExample() public {
assertTrue(true);
}
}
You are importing the contract, which you haven’t yet created, as well as ds-test/test.sol
Let’s also create EmojiGotchi.sol
in the src
directory. You can create it as an empty contract to ensure that everything is working.
src/EmojiGotchi.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;
contract EmojiGotchi {}
So we’ve got our test. It doesn’t test anything yet, but if you go ahead and run it via forge test
, you’ll see two test examples. This lets us know everything is set up and working.
❯ foundry (main) ✘ forge test
[⠢] Compiling...
Compiler run successful
Running 1 test for src/test/EmojiGotchi.t.sol:EmojiGotchiTest
[PASS] testExample() (gas: 212)
Test result: ok. 1 passed; 0 failed; finished in 1.85ms
Running 1 test for src/test/Contract.t.sol:ContractTest
[PASS] testExample() (gas: 190)
Test result: ok. 1 passed; 0 failed; finished in 1.85ms
❯ foundry (main) ✘
Clean Up
At this point, you can clean up the Contract.sol
contract and tests.
Delete src/Contract.sol
and src/test/Contract.t.sol
Writing the First Real Test
You’ve got your test file Contract.t.sol
, which has a test to ensure that true is true. When you run it, it’s currently just ensuring that our contract can be imported and that everything is running as expected.
Let’s take a moment and think about the EmojiGotchi again and note what we want it to do. This will provide a set of tests that we need to pass.
- mint the EmojiGotchi NFT
- set the metadata
- pass time
- feed the EmojiGotchi
- play with the EmojiGotchi
- change the image based on happiness
- check if upkeep is needed
- perform upkeep if needed
This list of tests should cover the basic functionality of the EmojiGotchi. If you head back to src/test/EmojiGotchi.t.sol
, you can flesh out the first test, testMint()
.
src/test/EmojiGotchi.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;
import "../EmojiGotchi.sol";
import "ds-test/test.sol";
contract EmojiGotchiTest is DSTest {
EmojiGotchi public eg;
function setUp() public {
eg = new EmojiGotchi();
address addr = 0x1234567890123456789012345678901234567890;
eg.safeMint(addr);
}
function testMint() public {
address addr = 0x1234567890123456789012345678901234567890;
address owner = eg.ownerOf(0);
assertEq(addr, owner);
}
}
You will need to modify the setUp()
function. This function is run before every test, and in this case, the actual minting will occur in the setUp()
function.
In the testMint()
function, you can verify that you minted an NFT in the setUp()
function and that it’s assigned to the expected owner.
If you run this as-is, you will see an error.
Error:
0: Compiler run failed
TypeError: Member "safeMint" not found or not visible after argument-dependent lookup in contract EmojiGotchi.
-->
/Users/rg/Development/EmojiGotchi/foundry/src/test/EmojiGotchi.t.sol:13:9:
|
13 | eg.safeMint(addr);
| ^^^^^^^^^^^
This is expected as we haven’t put anything in our EmojiGotchi.sol
contract yet.
A great place to start for NFTs is the OpenZeppelin Wizard, which provides an industry-standard skeleton to build off of. That is where we will be starting for the EmojiGotchi contract.
src/EmojiGotchi.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract EmojiGotchi is ERC721, ERC721URIStorage {
using Counters for Counters.Counter;
Counters.Counter private _tokenIdCounter;
constructor() ERC721("EmojiGotchi", "emg") {}
function safeMint(address to) public {
uint256 tokenId = _tokenIdCounter.current();
_tokenIdCounter.increment();
_safeMint(to, tokenId);
_setTokenURI(tokenId, tokenURI(tokenId));
}
// The following functions are overrides required by Solidity.
function _burn(uint256 tokenId)
internal
override(ERC721, ERC721URIStorage)
{
super._burn(tokenId);
}
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
}
If you rerun your test using forge test
, you will see you can successfully mint an NFT!
❯ foundry (main) ✘ forge test
[⠊] Compiling...
[⠆] Compiling 2 files with 0.8.13
[⠔] Solc finished in 218.31ms
Compiler run successful
Running 1 test for src/test/EmojiGotchi.t.sol:EmojiGotchiTest
[PASS] testMint() (gas: 7844)
Test result: ok. 1 passed; 0 failed; finished in 2.71ms
Testing the Metadata
This next test seems simple at first, but you will be building out a massive part of the contract to ensure it’s working. Add a new test: testUri()
src/test/EmojiGotchi.t.sol
function testUri() public {
(uint256 happiness, uint256 hunger, uint256 enrichment, uint256 checked, ) = eg.gotchiStats(
0
);
assertEq(happiness, (hunger + enrichment) / 2);
assertEq(hunger, 100);
assertEq(enrichment, 100);
assertEq(checked, block.timestamp);
}
This test will check that your contract has a function, gotchiStats
, which will return the happiness, hunger, enrichment stats for your NFT, plus the last time you checked for your NFT.
An Aside About SVGs
For the image portion of the EmojiGotchi, you will be using an SVG. In its raw form it will looking something like this:
<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%' viewBox='0 0 800 800'>
<rect fill='#ffffff' width='800' height='800' />
<defs>
<radialGradient id='a' cx='400' cy='400' r='50.1%' gradientUnits='userSpaceOnUse'>
<stop offset='0' stop-color='#ffffff' />
<stop offset='1' stop-color='#0EF' />
</radialGradient>
<radialGradient id='b' cx='400' cy='400' r='50.4%' gradientUnits='userSpaceOnUse'>
<stop offset='0' stop-color='#ffffff' />
<stop offset='1' stop-color='#0FF' />
</radialGradient>
</defs>
<rect fill='url(#a)' width='800' height='800' />
<g fill-opacity='0.5'>
<path fill='url(#b)' d='M998.7 439.2c1.7-26.5 1.7-52.7 0.1-78.5L401 399.9c0 0 0-0.1 0-0.1l587.6-116.9c-5.1-25.9-11.9-51.2-20.3-75.8L400.9 399.7c0 0 0-0.1 0-0.1l537.3-265c-11.6-23.5-24.8-46.2-39.3-67.9L400.8 399.5c0 0 0-0.1-0.1-0.1l450.4-395c-17.3-19.7-35.8-38.2-55.5-55.5l-395 450.4c0 0-0.1 0-0.1-0.1L733.4-99c-21.7-14.5-44.4-27.6-68-39.3l-265 537.4c0 0-0.1 0-0.1 0l192.6-567.4c-24.6-8.3-49.9-15.1-75.8-20.2L400.2 399c0 0-0.1 0-0.1 0l39.2-597.7c-26.5-1.7-52.7-1.7-78.5-0.1L399.9 399c0 0-0.1 0-0.1 0L282.9-188.6c-25.9 5.1-51.2 11.9-75.8 20.3l192.6 567.4c0 0-0.1 0-0.1 0l-265-537.3c-23.5 11.6-46.2 24.8-67.9 39.3l332.8 498.1c0 0-0.1 0-0.1 0.1L4.4-51.1C-15.3-33.9-33.8-15.3-51.1 4.4l450.4 395c0 0 0 0.1-0.1 0.1L-99 66.6c-14.5 21.7-27.6 44.4-39.3 68l537.4 265c0 0 0 0.1 0 0.1l-567.4-192.6c-8.3 24.6-15.1 49.9-20.2 75.8L399 399.8c0 0 0 0.1 0 0.1l-597.7-39.2c-1.7 26.5-1.7 52.7-0.1 78.5L399 400.1c0 0 0 0.1 0 0.1l-587.6 116.9c5.1 25.9 11.9 51.2 20.3 75.8l567.4-192.6c0 0 0 0.1 0 0.1l-537.3 265c11.6 23.5 24.8 46.2 39.3 67.9l498.1-332.8c0 0 0 0.1 0.1 0.1l-450.4 395c17.3 19.7 35.8 38.2 55.5 55.5l395-450.4c0 0 0.1 0 0.1 0.1L66.6 899c21.7 14.5 44.4 27.6 68 39.3l265-537.4c0 0 0.1 0 0.1 0L207.1 968.3c24.6 8.3 49.9 15.1 75.8 20.2L399.8 401c0 0 0.1 0 0.1 0l-39.2 597.7c26.5 1.7 52.7 1.7 78.5 0.1L400.1 401c0 0 0.1 0 0.1 0l116.9 587.6c25.9-5.1 51.2-11.9 75.8-20.3L400.3 400.9c0 0 0.1 0 0.1 0l265 537.3c23.5-11.6 46.2-24.8 67.9-39.3L400.5 400.8c0 0 0.1 0 0.1-0.1l395 450.4c19.7-17.3 38.2-35.8 55.5-55.5l-450.4-395c0 0 0-0.1 0.1-0.1L899 733.4c14.5-21.7 27.6-44.4 39.3-68l-537.4-265c0 0 0-0.1 0-0.1l567.4 192.6c8.3-24.6 15.1-49.9 20.2-75.8L401 400.2c0 0 0-0.1 0-0.1L998.7 439.2z' />
</g>
<text x='50%' y='50%' class='base' dominant-baseline='middle' text-anchor='middle' font-size='8em'>🤩</text>
</svg>
Everything up to the <text>
toward the end of the SVG will stay the same as the happiness level of the EmojiGotchi changes. The emoji within the <text>
is the dynamic portion of the SVG. You will be updating it based on the happiness level of the EmojiGotchi.
Adding SVGs, Structs, and Mappings
When you store this information inside the token URI, it needs to be base64 encoded. OpenZeppelin provides a library to accomplish this. In this example, you will be using a pre-encoded SVG version. Add these variables to the body of the contract.
src/EmojiGotchi.sol
string SVGBase =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScxMDAlJyBoZWlnaHQ9JzEwMCUnIHZpZXdCb3g9JzAgMCA4MDAgODAwJz48cmVjdCBmaWxsPScjZmZmZmZmJyB3aWR0aD0nODAwJyBoZWlnaHQ9JzgwMCcvPjxkZWZzPjxyYWRpYWxHcmFkaWVudCBpZD0nYScgY3g9JzQwMCcgY3k9JzQwMCcgcj0nNTAuMSUnIGdyYWRpZW50VW5pdHM9J3VzZXJTcGFjZU9uVXNlJz48c3RvcCAgb2Zmc2V0PScwJyBzdG9wLWNvbG9yPScjZmZmZmZmJy8+PHN0b3AgIG9mZnNldD0nMScgc3RvcC1jb2xvcj0nIzBFRicvPjwvcmFkaWFsR3JhZGllbnQ+PHJhZGlhbEdyYWRpZW50IGlkPSdiJyBjeD0nNDAwJyBjeT0nNDAwJyByPSc1MC40JScgZ3JhZGllbnRVbml0cz0ndXNlclNwYWNlT25Vc2UnPjxzdG9wICBvZmZzZXQ9JzAnIHN0b3AtY29sb3I9JyNmZmZmZmYnLz48c3RvcCAgb2Zmc2V0PScxJyBzdG9wLWNvbG9yPScjMEZGJy8+PC9yYWRpYWxHcmFkaWVudD48L2RlZnM+PHJlY3QgZmlsbD0ndXJsKCNhKScgd2lkdGg9JzgwMCcgaGVpZ2h0PSc4MDAnLz48ZyBmaWxsLW9wYWNpdHk9JzAuNSc+PHBhdGggZmlsbD0ndXJsKCNiKScgZD0nTTk5OC43IDQzOS4yYzEuNy0yNi41IDEuNy01Mi43IDAuMS03OC41TDQwMSAzOTkuOWMwIDAgMC0wLjEgMC0wLjFsNTg3LjYtMTE2LjljLTUuMS0yNS45LTExLjktNTEuMi0yMC4zLTc1LjhMNDAwLjkgMzk5LjdjMCAwIDAtMC4xIDAtMC4xbDUzNy4zLTI2NWMtMTEuNi0yMy41LTI0LjgtNDYuMi0zOS4zLTY3LjlMNDAwLjggMzk5LjVjMCAwIDAtMC4xLTAuMS0wLjFsNDUwLjQtMzk1Yy0xNy4zLTE5LjctMzUuOC0zOC4yLTU1LjUtNTUuNWwtMzk1IDQ1MC40YzAgMC0wLjEgMC0wLjEtMC4xTDczMy40LTk5Yy0yMS43LTE0LjUtNDQuNC0yNy42LTY4LTM5LjNsLTI2NSA1MzcuNGMwIDAtMC4xIDAtMC4xIDBsMTkyLjYtNTY3LjRjLTI0LjYtOC4zLTQ5LjktMTUuMS03NS44LTIwLjJMNDAwLjIgMzk5YzAgMC0wLjEgMC0wLjEgMGwzOS4yLTU5Ny43Yy0yNi41LTEuNy01Mi43LTEuNy03OC41LTAuMUwzOTkuOSAzOTljMCAwLTAuMSAwLTAuMSAwTDI4Mi45LTE4OC42Yy0yNS45IDUuMS01MS4yIDExLjktNzUuOCAyMC4zbDE5Mi42IDU2Ny40YzAgMC0wLjEgMC0wLjEgMGwtMjY1LTUzNy4zYy0yMy41IDExLjYtNDYuMiAyNC44LTY3LjkgMzkuM2wzMzIuOCA0OTguMWMwIDAtMC4xIDAtMC4xIDAuMUw0LjQtNTEuMUMtMTUuMy0zMy45LTMzLjgtMTUuMy01MS4xIDQuNGw0NTAuNCAzOTVjMCAwIDAgMC4xLTAuMSAwLjFMLTk5IDY2LjZjLTE0LjUgMjEuNy0yNy42IDQ0LjQtMzkuMyA2OGw1MzcuNCAyNjVjMCAwIDAgMC4xIDAgMC4xbC01NjcuNC0xOTIuNmMtOC4zIDI0LjYtMTUuMSA0OS45LTIwLjIgNzUuOEwzOTkgMzk5LjhjMCAwIDAgMC4xIDAgMC4xbC01OTcuNy0zOS4yYy0xLjcgMjYuNS0xLjcgNTIuNy0wLjEgNzguNUwzOTkgNDAwLjFjMCAwIDAgMC4xIDAgMC4xbC01ODcuNiAxMTYuOWM1LjEgMjUuOSAxMS45IDUxLjIgMjAuMyA3NS44bDU2Ny40LTE5Mi42YzAgMCAwIDAuMSAwIDAuMWwtNTM3LjMgMjY1YzExLjYgMjMuNSAyNC44IDQ2LjIgMzkuMyA2Ny45bDQ5OC4xLTMzMi44YzAgMCAwIDAuMSAwLjEgMC4xbC00NTAuNCAzOTVjMTcuMyAxOS43IDM1LjggMzguMiA1NS41IDU1LjVsMzk1LTQ1MC40YzAgMCAwLjEgMCAwLjEgMC4xTDY2LjYgODk5YzIxLjcgMTQuNSA0NC40IDI3LjYgNjggMzkuM2wyNjUtNTM3LjRjMCAwIDAuMSAwIDAuMSAwTDIwNy4xIDk2OC4zYzI0LjYgOC4zIDQ5LjkgMTUuMSA3NS44IDIwLjJMMzk5LjggNDAxYzAgMCAwLjEgMCAwLjEgMGwtMzkuMiA1OTcuN2MyNi41IDEuNyA1Mi43IDEuNyA3OC41IDAuMUw0MDAuMSA0MDFjMCAwIDAuMSAwIDAuMSAwbDExNi45IDU4Ny42YzI1LjktNS4xIDUxLjItMTEuOSA3NS44LTIwLjNMNDAwLjMgNDAwLjljMCAwIDAuMSAwIDAuMSAwbDI2NSA1MzcuM2MyMy41LTExLjYgNDYuMi0yNC44IDY3LjktMzkuM0w0MDAuNSA0MDAuOGMwIDAgMC4xIDAgMC4xLTAuMWwzOTUgNDUwLjRjMTkuNy0xNy4zIDM4LjItMzUuOCA1NS41LTU1LjVsLTQ1MC40LTM5NWMwIDAgMC0wLjEgMC4xLTAuMUw4OTkgNzMzLjRjMTQuNS0yMS43IDI3LjYtNDQuNCAzOS4zLTY4bC01MzcuNC0yNjVjMCAwIDAtMC4xIDAtMC4xbDU2Ny40IDE5Mi42YzguMy0yNC42IDE1LjEtNDkuOSAyMC4yLTc1LjhMNDAxIDQwMC4yYzAgMCAwLTAuMSAwLTAuMUw5OTguNyA0MzkuMnonLz48L2c+PHRleHQgeD0nNTAlJyB5PSc1MCUnIGNsYXNzPSdiYXNlJyBkb21pbmFudC1iYXNlbGluZT0nbWlkZGxlJyB0ZXh0LWFuY2hvcj0nbWlkZGxlJyBmb250LXNpemU9JzhlbSc+8J+';
string[] emojiBase64 = [
'kqTwvdGV4dD48L3N2Zz4=',
'YgTwvdGV4dD48L3N2Zz4=',
'YkDwvdGV4dD48L3N2Zz4=',
'YoTwvdGV4dD48L3N2Zz4=',
'SgDwvdGV4dD48L3N2Zz4='
];
SVGBase
is a string that contains all of the encoded SVG up until the emoji itself.
emojiBase64
is an array of the different emojis and closes the SVG string.
In addition to the SVG variables, you will need to create a struct
for the attributes of the EmojiGotchi, GotchiAttributs
, as well as a mapping of NFT holders to their token id, gotchiHolders
, and a mapping from the token id to the attributes, gotchiHolderAttributes
.
src/EmojiGotchi.sol
struct GotchiAttributes {
uint256 gotchiIndex;
string imageURI;
uint256 happiness;
uint256 hunger;
uint256 enrichment;
uint256 lastChecked;
}
mapping(address => uint256) public gotchiHolders;
mapping(uint256 => GotchiAttributes) public gotchiHolderAttributes;
Updating The Minting Process
The next step is to update the safeMint()
function to include the metadata we need and populate the mappings.
src/EmojiGotchi.sol
function safeMint(address to) public {
// Grab the current counter
uint256 tokenId = _tokenIdCounter.current();
// Increase it
_tokenIdCounter.increment();
// Mint the token
_safeMint(to, tokenId);
// Set the inital SVG to the first emoji value
string memory finalSVG = string(abi.encodePacked(SVGBase, emojiBase64[0]));
// Set attributes for the token to the default
gotchiHolderAttributes[tokenId] = GotchiAttributes({
gotchiIndex: tokenId,
imageURI: finalSVG,
happiness: 100,
hunger: 100,
enrichment: 100,
lastChecked: block.timestamp
});
// Map the token to the holder/minter
gotchiHolders[msg.sender] = tokenId;
// Set the URI for the token to include all of the attributes
_setTokenURI(tokenId, tokenURI(tokenId));
}
Once safeMint()
is updated, you will need to update the output of the tokenURI()
function to include the proper JSON based on our attributes. This is perhaps the most challenging portion of this contract. Not from a technical aspect, simply due to matching opening and closing quotes. The result will look something like this.
{
"name": "Your Little Emoji Friend",
"description": "Keep your pet happy!",
"image": <image URI Here>,
"traits": [
{"trait_type": "Hunger", "value": "100"},
{"trait_type": "Enrichment", "value": "100"},
{"trait_type": "Happiness", "value": "100"}
]
}
Update the tokenURI()
function to include the attributes and return a JSON object.
src/EmojiGotchi.sol
function tokenURI(uint256 _tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
// Select the attributes for the token we are referencing
GotchiAttributes memory gotchiAttributes = gotchiHolderAttributes[_tokenId];
// The result of this function is a string.
// Each of these values, happiness, hunger, enrichment
// is stored as a uint256, they need to be converted to strings
string memory strHappiness = Strings.toString(gotchiAttributes.happiness);
string memory strHunger = Strings.toString(gotchiAttributes.hunger);
string memory strEnrichment = Strings.toString(gotchiAttributes.enrichment);
// abi.encodePacked is used to combine the strings into a single value.
string memory json = string(
abi.encodePacked(
'{"name": "Your Little Emoji Friend",',
'"description": "Keep your pet happy!",',
'"image": "',
gotchiAttributes.imageURI,
'",',
'"traits": [',
'{"trait_type": "Hunger","value": ',
strHunger,
'}, {"trait_type": "Enrichment", "value": ',
strEnrichment,
'}, {"trait_type": "Happiness","value": ',
strHappiness,
'}]',
'}'
)
);
return json;
}
Return the Stats for an Emojigotchi
Once you have created the mappings and updated the minting and token URI, you are ready to add the function we tested with testURI()
. The test is expecting, in this order, happiness, hunger, enrichment, last checked, and the image. Each of these is stored in the mapping from token id to attributes gotchiHolderAttributes
. You can use that to return the values based on the token id passed in.
src/EmojiGotchi.sol
function gotchiStats(uint256 _tokenId)
public
view
returns (
uint256,
uint256,
uint256,
uint256,
string memory
)
{
return (
gotchiHolderAttributes[_tokenId].happiness,
gotchiHolderAttributes[_tokenId].hunger,
gotchiHolderAttributes[_tokenId].enrichment,
gotchiHolderAttributes[_tokenId].lastChecked,
gotchiHolderAttributes[_tokenId].imageURI
);
}
Once you add in this function, your test should pass.
❯ foundry (main) ✘ forge test
[⠊] Compiling...
[⠰] Compiling 2 files with 0.8.13
[⠔] Solc finished in 294.37ms
Compiler run successful
Running 2 tests for src/test/EmojiGotchi.t.sol:EmojiGotchiTest
[PASS] testMint() (gas: 7844)
[PASS] testUri() (gas: 248121)
Test result: ok. 2 passed; 0 failed; finished in 2.61ms
❯ foundry (main) ✘
Returning Your EmojiGotchi
The next test will look similar to testUri()
, with a minor difference. In testUri()
, you passed in an id for the NFT. You will be modifying this to return the stats of the EmojiGotchi the msg.sender
owns.
src/test/EmojiGotchi.t.sol
function testMyGotchi() public {
(uint256 happiness, uint256 hunger, uint256 enrichment, uint256 checked, ) = eg.myGotchi() ;
assertEq(happiness, (hunger + enrichment) / 2);
assertEq(hunger, 100);
assertEq(enrichment, 100);
assertEq(checked, block.timestamp);
}
This test will fail until you add the myGotchi()
function. This function will pass the msg.sender
to the gotchiHolders
mapping to return the id of the EmojiGotchi and retrieve its stats.
src/EmojiGotchi.sol
function myGotchi()
public
view
returns (
uint256,
uint256,
uint256,
uint256,
string memory
)
{
return gotchiStats(gotchiHolders[msg.sender]);
}
Enabling the Passage of Time
Now that you have a functional EmojiGotchi NFT, you will need to enable time to pass. In this case, that means you will need to create a function to decrease how well-fed and enriched your EmojiGotchi is. First, you should build the test.
src/test/EmojiGotchi.t.sol
function testPassTime() public {
// Pass time for a specific NFT Id
eg.passTime(0);
// The empty value is the last time checked and image
// which we aren't using in this test
(uint256 happiness, uint256 hunger, uint256 enrichment, , ) = eg.gotchiStats(0);
// passTime should reduce hunger and enrichment by 10
assertEq(hunger, 90);
assertEq(enrichment, 90);
assertEq(happiness, (90 + 90) / 2);
}
Once the test is added, your tests should be failing again.
❯ foundry (main) ✘ forge test
[⠊] Compiling...
[⠢] Compiling 2 files with 0.8.13
[⠆] Solc finished in 19.76ms
Error:
0: Compiler run failed
TypeError: Member "passTime" not found or not visible after argument-dependent lookup in contract EmojiGotchi.
--> /Users/rg/Development/EmojiGotchi/foundry/src/test/EmojiGotchi.t.sol:52:9:
|
52 | eg.passTime(0);
| ^^^^^^^^^^^
0:
Location:
cli/src/compile.rs:88
Backtrace omitted.
Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.
❯ foundry (main) ✘
You will need to decrease the hunger and enrichment value for the EmojiGotchi in the passTime()
function. Once you have stored the hunger, enrichment, and happiness attributes, you will need to update the URI. This is where the emoji is changed. You will reference emojiBase64
, which was set up earlier. It’s an array of the following emojis:
🤩, 😀, 😐, 😡, ☠️
Thinking about the logic for these emojis, 🤩 & ☠️ represent the maximum happiness, 100, and minimum, 0. The other three, 😀, 😐, & 😡 will be based on happiness above 66, 33, and 0, respectively.
src/EmojiGotchi.sol
function passTime(uint256 _tokenId) public {
// decrease hunger
gotchiHolderAttributes[_tokenId].hunger = gotchiHolderAttributes[_tokenId].hunger - 10;
// decrease enrichment
gotchiHolderAttributes[_tokenId].enrichment =
gotchiHolderAttributes[_tokenId].enrichment -
10;
// recalculate happiness
gotchiHolderAttributes[_tokenId].happiness =
(gotchiHolderAttributes[_tokenId].hunger +
gotchiHolderAttributes[_tokenId].enrichment) /
2;
// update the URI
updateURI(_tokenId)
}
function updateURI(uint256 _tokenId) private {
// store the base case
string memory emojiB64 = emojiBase64[0];
// if happiness is 100: 🤩
if (gotchiHolderAttributes[_tokenId].happiness == 100) {
emojiB64 = emojiBase64[0];
// if happiness is < 100 and > 66 😀
} else if (gotchiHolderAttributes[_tokenId].happiness > 66) {
emojiB64 = emojiBase64[1];
// if happiness is <= 66 and > 33 😐
} else if (gotchiHolderAttributes[_tokenId].happiness > 33) {
emojiB64 = emojiBase64[2];
// if happiness is between 33 and greater than 0 😡
} else if (gotchiHolderAttributes[_tokenId].happiness > 0) {
emojiB64 = emojiBase64[3];
// if your emojigotchi is 'dead' happiness 0 ☠️
} else if (gotchiHolderAttributes[_tokenId].happiness == 0) {
emojiB64 = emojiBase64[4];
}
// repack the SVG string with the emoji
string memory finalSVG = string(abi.encodePacked(SVGBase, emojiB64));
//update the attributes for the token
gotchiHolderAttributes[_tokenId].imageURI = finalSVG;
// set the token URI to the new values
_setTokenURI(_tokenId, tokenURI(_tokenId));
}
Once you add these functions, the tests should be back to passing.
❯ foundry (main) ✘ forge test
[⠊] Compiling...
[⠰] Compiling 2 files with 0.8.13
[⠒] Solc finished in 321.33ms
Compiler run successful
Running 4 tests for src/test/EmojiGotchi.t.sol:EmojiGotchiTest
[PASS] testMint() (gas: 7977)
[PASS] testMyGotchi() (gas: 250299)
[PASS] testPassTime() (gas: 806103)
[PASS] testUri() (gas: 248099)
Test result: ok. 4 passed; 0 failed; finished in 3.51ms
❯ foundry (main) ✘
Having Fun With Your EmojiGotchi
Now that you’ve created a way to cause your EmojiGotchi to be bored and hungry, you need to play with it and feed it. Again, start with the tests. These tests are very similar, so that we can write them simultaneously.
src/test/EmojiGotchi.t.sol
function testFeed() public {
eg.passTime(0);
eg.feed();
(uint256 happiness, uint256 hunger, , , ) = eg.gotchiStats(0);
assertEq(hunger, 100);
assertEq(happiness, (100 + 90) / 2);
}
function testPlay() public {
eg.passTime(0);
eg.play();
(uint256 happiness, , uint256 enrichment, , ) = eg.gotchiStats(0);
assertEq(enrichment, 100);
assertEq(happiness, (90 + 100) / 2);
}
To satisfy these tests you will need a feed()
and play()
function, which sets their respective attributes to 100 and recalculates happiness.
src/EmojiGotchi.sol
function feed() public {
// retrieve the token based on the sender.
uint256 _tokenId = gotchiHolders[msg.sender];
// update hunger
gotchiHolderAttributes[_tokenId].hunger = 100;
// recalculate happiness
gotchiHolderAttributes[_tokenId].happiness =
(gotchiHolderAttributes[_tokenId].hunger +
gotchiHolderAttributes[_tokenId].enrichment) /
2;
// update the URI based on new attributes
updateURI(_tokenId);
}
function play() public {
// retrieve the token based on the sender.
uint256 _tokenId = gotchiHolders[msg.sender];
// update enrichment
gotchiHolderAttributes[_tokenId].enrichment = 100;
// recalculate happiness
gotchiHolderAttributes[_tokenId].happiness =
(gotchiHolderAttributes[_tokenId].hunger +
gotchiHolderAttributes[_tokenId].enrichment) /
2;
// update the URI based on new attributes
updateURI(_tokenId);
}
Now you can play with and feed your EmojiGotchi, and your tests are back to passing.
❯ foundry (main) ✘ forge test
[⠊] Compiling...
[⠰] Compiling 2 files with 0.8.13
[⠒] Solc finished in 330.95ms
Compiler run successful
Running 6 tests for src/test/EmojiGotchi.t.sol:EmojiGotchiTest
[PASS] testFeed() (gas: 890794)
[PASS] testMint() (gas: 7999)
[PASS] testMyGotchi() (gas: 250321)
[PASS] testPassTime() (gas: 806125)
[PASS] testPlay() (gas: 893614)
[PASS] testUri() (gas: 248099)
Test result: ok. 6 passed; 0 failed; finished in 3.87ms
❯ foundry (main) ✘
Ensuring the Image URI Updates
When you implemented the updateURI()
function, there wasn’t a test to ensure it was working. While this may be slightly backward in terms of test-drive development, you can fix that gap.
You will be adding a helper function to compare strings and ensure they are not the same. In Solidity, comparing strings is best accomplished by encoding them into a hash. Keccak256 returns a bytes32 hash determined by the input. No matter what the input is, Keccak256 will return a 64-character value.
src/test/EmojiGotchi.t.sol
function compareStringsNot(string memory a, string memory b) public pure returns (bool) {
return (keccak256(abi.encodePacked((a))) != keccak256(abi.encodePacked((b))));
}
Now, you can add a test to ensure that the image for the token is changing when it should.
src/test/EmojiGotchi.t.sol
function testImgURI() public {
string memory tokenURI = '';
(, , , , tokenURI) = eg.gotchiStats(0);
string memory firstURI = tokenURI;
eg.passTime(0);
eg.passTime(0);
eg.passTime(0);
(, , , , tokenURI) = eg.gotchiStats(0);
string memory secondURI = tokenURI;
assertTrue(compareStringsNot(firstURI, secondURI));
}
Once both of these functions are added to the test contract, you should be back to passing.
Testing Upkeep
At this point, you have created an EmojiGotchi that can experience time passing only when you manually pass time. Using Chainlink Automation, you will be able to automate your contract, enabling time to pass at a regular interval.
In order to test this, you are going to need to use a cheat code provided by Foundry. Before the contract is defined in src/test/EmojiGotchi.t.sol
, add the following interface and instantiate it within the contract.
src/test/EmojiGotchi.t.sol
interface CheatCodes {
function warp(uint256) external;
}
contract EmojiGotchiTest is DSTest {
CheatCodes constant cheats = CheatCodes(HEVM_ADDRESS);
Then, within the EmojiGotchiTest contract, add another function to testUpkeep()
.
src/test/EmojiGotchi.t.sol
function testUpkeep() public {
bytes memory data = '';
bool upkeepNeeded = false;
(upkeepNeeded, ) = eg.checkUpkeep(data);
assertTrue(upkeepNeeded == false);
cheats.warp(block.timestamp + 100);
(upkeepNeeded, ) = eg.checkUpkeep(data);
assertTrue(upkeepNeeded);
}
This test uses cheats.warp
to warp forward in time, so we will need upkeep. The checkUpkeep()
function will return a Boolean letting the Keepers network know if the contract needs an upkeep performed.
To make our contract Automation Compatible, we require two functions. The first is checkUpkeep()
which will return the Boolean mentioned above; the second is performUpkeep()
, which actually makes the change to the blockchain. You can add both of those to your contract as follows.
src/EmojiGotchi.sol
function checkUpkeep(
bytes calldata /* checkData */
)
external
view
returns (
bool upkeepNeeded,
bytes memory /* performData */
)
{
// The last time the EmojiGotchi was updated
uint256 lastTimeStamp = gotchiHolderAttributes[0].lastChecked;
// If the EmojiGotchi's happiness is > 0 and
// it's been more than 60s since last check
// return true
// ** NOTE this example hard codes the first token
// minted for this check. [0]
upkeepNeeded = (gotchiHolderAttributes[0].happiness > 0 &&
(block.timestamp - lastTimeStamp) > 60);
// We don't use the checkData in this example. The checkData is defined when the Upkeep was registered.
}
function performUpkeep(
bytes calldata /* performData */
) external {
uint256 lastTimeStamp = gotchiHolderAttributes[0].lastChecked;
//We highly recommend revalidating the upkeep in the performUpkeep function
// Run the same checks as checkUpkeep()
if (
gotchiHolderAttributes[0].happiness > 0 &&
((block.timestamp - lastTimeStamp) > 60)
) {
// update the last checked value to now
gotchiHolderAttributes[0].lastChecked = block.timestamp;
// run passTime
// ** NOTE this example hard codes the first token
// minted for this check. [0]
passTime(0);
}
// We don't use the performData in this example. The performData is generated by the Keeper's call to your checkUpkeep function
}
With these additions, you have a fully functional, Keeper-compatible, on-chain NFT EmojiGotchi! Congratulations!
There is one more addition to the contract you need to make: You need to add an event that you will emit every time the emoji is updated. At the top of your contract, add the event definition:
src/EmojiGotchi.sol
event EmojiUpdated(
uint256 happiness,
uint256 hunger,
uint256 enrichment,
uint256 checked,
string uri,
uint256 index
);
Then you will need to add a function to actually emit the update as well as add a call to the end of the updateURI()
function.
The new function:
src/EmojiGotchi.sol
function emitUpdate(uint256 _tokenId) internal {
emit EmojiUpdated(
gotchiHolderAttributes[_tokenId].happiness,
gotchiHolderAttributes[_tokenId].hunger,
gotchiHolderAttributes[_tokenId].enrichment,
gotchiHolderAttributes[_tokenId].lastChecked,
gotchiHolderAttributes[_tokenId].imageURI,
_tokenId
);
}
Change updateURI()
to reflect the addition of this new event.
function updateURI(uint256 _tokenId) private {
// store the base case
string memory emojiB64 = emojiBase64[0];
// if happiness is 100: 🤩
if (gotchiHolderAttributes[_tokenId].happiness == 100) {
emojiB64 = emojiBase64[0];
// if happiness is < 100 and > 66 😀
} else if (gotchiHolderAttributes[_tokenId].happiness > 66) {
emojiB64 = emojiBase64[1];
// if happiness is <= 66 and > 33 😐
} else if (gotchiHolderAttributes[_tokenId].happiness > 33) {
emojiB64 = emojiBase64[2];
// if happiness is between 33 and greater than 0 😡
} else if (gotchiHolderAttributes[_tokenId].happiness > 0) {
emojiB64 = emojiBase64[3];
// if your emojigotchi is 'dead' happiness 0 ☠️
} else if (gotchiHolderAttributes[_tokenId].happiness == 0) {
emojiB64 = emojiBase64[4];
}
// repack the SVG string with the emoji
string memory finalSVG = string(abi.encodePacked(SVGBase, emojiB64));
//update the attributes for the token
gotchiHolderAttributes[_tokenId].imageURI = finalSVG;
// set the token URI to the new values
_setTokenURI(_tokenId, tokenURI(_tokenId));
emitUpdate(_tokenId);
}
Contract Completed!
You did it! The contract is ready to be deployed! You can use deploy.sh
in the foundry
directory to deploy your NFT contract. If you would like to mint one NFT to yourself on deploy, add the following to the constructor.
/src/EmojiGotchi.sol
constructor() ERC721("EmojiGotchi", "emg") {
safeMint(msg.sender);
}
Once you run deploy.sh
you should see where your contract was deployed to. You will need this address for the next section, in which we’ll build out the frontend.
Deployer: 0x0000000000000000000000000000000000000000
Deployed to: 0x1234567890123456789012345678901234567890
Transaction hash: 0x1234567890123456789012345678901234567890594be2f670606ada53412aaa
Building a SvelteKit Frontend
The second part of this tutorial will walk you through building out a SvelteKit-based frontend for the EmojiGotchi. This tutorial is focused on functionality and will leave the design choices up to you.
SvelteKit Skeleton
The starter project begins with the SvelteKit Skeleton project and includes the addition of ethers. In order to get started you will need to install everything with the svelte
directory
❯ svelte (main) ✔ npm install
> svelte@0.0.1 prepare
> svelte-kit sync
added 209 packages, and audited 210 packages in 1s
53 packages are looking for fundin
run `npm fund` for details
found 0 vulnerabilities
❯ svelte (main) ✔
At this point you should be able to start the Svelte server and see the Welcome to SvelteKit page.
❯ svelte (main) ✔ npm run dev
> svelte@0.0.1 dev
> svelte-kit dev
SvelteKit v1.0.0-next.325
local: http://localhost:3000
network: not exposed
Use --host to expose server to other devices on this network
SvelteKit will hot reload any changes you make to src/routes/index.svelte
, if you change that file it should be reflected in your browser.
src/routes/index.svelte<h1>My EmojiGotchi</h1>This is where you can see your EmojiGotchi.
With this up and running you can create your first component.
Connecting Your Wallet
You will need to create a component in the src/lib
directory. To start simply and ensure everything is working just create a single button in the component for now.
src/lib/WalletConnect.svelte
<button>Attach Wallet</button>
Then within src/routes/index.svelte
you can import this new component. Ensuring that the component is imported correctly is a great practice before fleshing it out completely. It also allows you to see incremental changes as you build the component out via hot reload.
src/routes/index.svelte
<script>
import WalletConnect from '$lib/WalletConnect.svelte';
</script>
<h1>My EmojiGotchi</h1>
<WalletConnect />
This should provide you with the following change to your page.
Once this is working we can build out the rest of the components. I won’t be demonstrating these steps going forward but remember to create the component before importing them.
In order to pass the contract and wallet between components, you will need to create a place in which to store them. You can create a web3Props
object that will hold this information.
src/lib/WalletConnect.svelte
<script>
import { ethers } from 'ethers';
// place holder for the properties we will be passing between components
export let web3Props = { provider: null, signer: null, account: null, chainId: null };
// connect the wallet
async function connectWallet() {
// get the provider, this time without ethereum object
let provider = new ethers.providers.Web3Provider(window.ethereum, 'any');
// prompt user for account connections
await provider.send('eth_requestAccounts', []);
// get the signer
const signer = provider.getSigner();
// get the account address
const account = await signer.getAddress();
// get the chainId
const chainId = await signer.getChainId();
// update the props
web3Props = { signer, provider, chainId, account };
}
</script>
<button on:click={connectWallet}>Attach Wallet</button>
Once the component is updated you will need to pass the props from index.svelte
into the component.
src/routes/index.svelte
<script>
import WalletConnect from '$lib/WalletConnect.svelte';
export let web3Props = {
provider: null,
signer: null,
account: null,
chainId: null
};
</script>
<h1>My EmojiGotchi</h1>
{#if !web3Props.account}
<WalletConnect bind:web3Props />
{:else}
😎
{/if}
Adding Your Contract
Now that you have the ability to attach a wallet to your frontend you will need to import the contract information. The first step is to create a new directory, src/contracts
, which will house the JSON ABI for your contract. If you head back to the foundry
directory within foundry/out/EmojiGotchi.sol
you will find EmojiGotchi.json
. Copy this file into src/contracts
within the svelte
portion of the project.
This file houses the ABI for your contract. You will need to import it and add the information to the web3Props
as well as the WalletConnect
component. Additionally, you will need to add a constant for the contract address. This is the value from deploying your contract above.
src/routes/index.svelte
<script>
// new import
import EmojiGotchiAbi from '../contracts/EmojiGotchi.json';
// new constant for contract address
const contractAddr = '<PLACE HOLDER>';
export let web3Props = {
provider: null,
signer: null,
account: null,
chainId: null,
// new prop
contract: null
};
</script>
{#if !web3Props.account}
// new values passed to component
<WalletConnect bind:web3Props {contractAddr} contractAbi={EmojiGotchiAbi} />
{:else}
😎
{/if}
src/lib/WalletConnect.svelte
<script>
import { ethers } from 'ethers';
export let web3Props = {
provider: null,
signer: null,
account: null,
chainId: null,
// new prop
contract: null
};
// new variable for the contract address
export let contractAddr = '';
// new variable for the contract ABI
export let contractAbi = { abi: null };
async function connectWallet() {
let provider = new ethers.providers.Web3Provider(window.ethereum, 'any');
await provider.send('eth_requestAccounts', []);
const signer = provider.getSigner();
const account = await signer.getAddress();
const chainId = await signer.getChainId();
// new contract variable
const contract = new ethers.Contract(contractAddr, contractAbi.abi, signer);
// new value for contract
web3Props = { signer, provider, chainId, account, contract };
}
</script>
<button on:click={connectWallet}>Attach Wallet</button>
Fantastic! At this point, you have your wallet connected to the blockchain and you have all of the contract information ready to go. Let’s build out the EmojiGotchi interface.
Building The EmojiGotchi Component
Taking a look at the end result we are working towards we need to build a few things, the EmojiGotchi image, progress bars, the values, and a couple of buttons.
First, you’ll be building out a few sub-components. You will start by building the face of your EmojiGotchi.
src/lib/Face.svelte
<script>
export let image;
</script>
<img src={image} alt="Your EmojiGotchi" />
This is a very simple component, it takes in an image, in this case an SVG which is base64 encoded, and displays it.
Next, you can build out the progress bar component to show how much happiness or enrichment is left.
src/lib/Bar.svelte
<script>
export let status;
</script>
<div class="status" style={`width: ${status}%;`} />
<style>
.status {
height: 8px;
background: blue;
width: 33%;
}
</style>
With these two components in place, you are able to build out the full EmojiGotchi component.
src/lib/EmojiGotchi.svelte
<script>
import Bar from './Bar.svelte';
import Face from './Face.svelte';
export let web3Props = {
provider: null,
signer: null,
account: null,
chainId: null,
contract: null
};
// set placeholders for variables
// $: in svelte denotes a reactive declaration
// it will be updated when the value changes
$: image = '';
$: hunger = 0;
$: enrichment = 0;
$: happiness = 0;
const getMyGotchi = async () => {
// get the contract instance
let currentGotchi = await web3Props.contract.myGotchi();
// get the current gotchi's image
image = await currentGotchi[4];
// get the current gotchi's happiness
happiness = await currentGotchi[0].toNumber();
// get the current gotchi's hunger
hunger = await currentGotchi[1].toNumber();
// get the current gotchi's enrichment
enrichment = await currentGotchi[2].toNumber();
// Listen for the EmojiUpdated event and update the values
web3Props.contract.on('EmojiUpdated', async () => {
currentGotchi = await web3Props.contract.myGotchi();
image = await currentGotchi[4];
happiness = await currentGotchi[0].toNumber();
hunger = await currentGotchi[1].toNumber();
enrichment = await currentGotchi[2].toNumber();
});
};
getMyGotchi();
</script>
<div>
<Face {image} />
</div>
<div>
Hunger: {hunger}
<br />
<Bar bind:status={hunger} />
<button
on:click={() => {
web3Props.contract.feed();
}}>Feed</button
>
</div>
<div>
Enrichment: {enrichment}
<br />
<Bar bind:status={enrichment} />
<button
on:click={() => {
web3Props.contract.play();
}}>Play</button
>
</div>
<div>
Happiness: {happiness}
<Bar bind:status={happiness} />
</div>
<style>
div {
width: 33%;
}
</style>
Connect the EmojiGotchi Component
If you haven’t already, the last step is to add this component to index.svelte
.
<script>
import EmojiGotchiAbi from '../contracts/EmojiGotchi.json';
import WalletConnect from '$lib/WalletConnect.svelte';
import EmojiGotchi from '../lib/EmojiGotchi.svelte';
const contractAddr = '<PLACE HOLDER>';
export let web3Props = {
provider: null,
signer: null,
account: null,
chainId: null,
contract: null
};
</script>
<h1>My EmojiGotchi</h1>
{#if !web3Props.account}
<WalletConnect bind:web3Props {contractAddr} contractAbi={EmojiGotchiAbi} />
{:else}
<EmojiGotchi bind:web3Props />
{/if}
Make It Dynamic
Now that you have a fully complete dApp, there is one final step. You need to head to automation.chain.link and register a new Upkeep. Fill in the relevant information and submit your upkeep. As you watch your EmojiGotchi, you will see its stats decreasing. Make sure you keep it happy!
Learn more about Chainlink by visiting chain.link or reading the documentation at docs.chain.link. To discuss an integration, reach out to an expert.
Disclaimer