How I built a Christmas themed NFT game

Sukhpal Saini
4 min readJan 13, 2022

I wanted to work on a Web3 project in December 2021 so clearly, I built an NFT themed Christmas game.

Characters

I tried my hand at drawing some pixel artwork but it was TERRIBLE. I ended up buying some online.

Gameplay

  • The player logs in with their Rinkeby testnet account.
  • They choose 1 character to mint:
  • Based on the character, they are placed either in either of the teams: Santa Squad or the Grinch Pack.
  • Every player has Attack Damage, HP, and Healing Power.
  • They can attack someone on the enemy team or heal someone on their team.
  • The idea of the game is for the players to fight each other until Christmas day hits. The team with the most surviving members wins the brawl.
  • No more minting or gameplay is allowed after Christmas Day 2021.

Smart Contract

I followed an ERC-721 contract tutorial on buildspace and modified it with my own rules.

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./libraries/Base64.sol";

import "hardhat/console.sol";

contract SantaWars is ERC721 {

struct CharacterAttributes {
uint characterIndex;
string name;
string imageURI;
uint hp;
uint maxHp;
uint attackDamage;
uint healingPower;
}

using Counters for Counters.Counter;
Counters.Counter private _tokenIds;

uint public deadlineTime;
CharacterAttributes[] defaultCharacters;

mapping(uint256 => CharacterAttributes) public nftHolderAttributes;

mapping(address => uint256) public nftHolders;
uint public totalNFTHolders;

address[] allPlayers;
mapping(address => bool) userExists;

constructor(
string[] memory characterNames,
string[] memory characterImageURIs,
uint[] memory characterHp,
uint[] memory characterAttackDmg,
uint[] memory characterHealingPwr
)
ERC721("Christmasers", "CRMS")
{
for(uint i = 0; i < characterNames.length; i += 1) {
defaultCharacters.push(CharacterAttributes({
characterIndex: i,
name: characterNames[i],
imageURI: characterImageURIs[i],
hp: characterHp[i],
maxHp: characterHp[i],
attackDamage: characterAttackDmg[i],
healingPower: characterHealingPwr[i]
}));

CharacterAttributes memory c = defaultCharacters[i];

console.log("Done initializing %s w/ HP %s, img %s", c.name, c.hp, c.imageURI);
}

deadlineTime = 1640455200;

_tokenIds.increment();
}

// _characterIndex -> which character you want to mint
function mintCharacterNFT(uint _characterIndex) external {

require (
!userExists[msg.sender],
"Error: You already own an NFT"
);

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,
attackDamage: defaultCharacters[_characterIndex].attackDamage,
healingPower: defaultCharacters[_characterIndex].healingPower
});

console.log("Minted NFT w/ tokenId %s and characterIndex %s", newItemId, _characterIndex);

nftHolders[msg.sender] = newItemId;
allPlayers.push(msg.sender);
userExists[msg.sender] = true;

_tokenIds.increment();

emit CharacterNFTMinted(msg.sender, newItemId, _characterIndex);
}

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 strAttackDamage = Strings.toString(charAttributes.attackDamage);
string memory strHealingPower = Strings.toString(charAttributes.healingPower);

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 game SantaWars game", "image": "',
charAttributes.imageURI,
'", "attributes": [ { "trait_type": "Health Points", "value": ',strHp,', "max_value":',strMaxHp,'}, { "trait_type": "Attack Damage", "value": ', strAttackDamage,'}, { "trait_type": "Healing Power", "value": ', strHealingPower,'} ]}'
)
)
)
);

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

return output;
}

function attack(address targetAddress) public {
uint time = block.timestamp;
require (
time < deadlineTime,
"Error: contract was only valid until Dec 25, 2021."
);

uint256 nftTokenIdOfTarget = nftHolders[targetAddress];
CharacterAttributes storage target = nftHolderAttributes[nftTokenIdOfTarget];

uint256 nftTokenIdOfPlayer = nftHolders[msg.sender];
CharacterAttributes storage player = nftHolderAttributes[nftTokenIdOfPlayer];

console.log("\nPlayer w/ character %s about to attack. Has %s HP and %s AD", player.name, player.hp, player.attackDamage);
console.log("Target %s has %s HP and %s AD", target.name, target.hp, target.attackDamage);

require (
player.hp > 0,
"Error: character must have HP to attack target."
);

require (
target.hp > 0,
"Error: target must have HP to be attacked."
);

if (target.hp < player.attackDamage) {
target.hp = 0;
} else {
target.hp = target.hp - player.attackDamage;
}

console.log("Player attacked target. New target hp: %s", target.hp);
emit AttackComplete(target.hp, player.hp);
}

function heal(address targetAddress) public {
uint time = block.timestamp;
require (
time < deadlineTime,
"Error: contract was only valid until Dec 25, 2021."
);

uint256 nftTokenIdOfTarget = nftHolders[targetAddress];
CharacterAttributes storage target = nftHolderAttributes[nftTokenIdOfTarget];

uint256 nftTokenIdOfPlayer = nftHolders[msg.sender];
CharacterAttributes storage player = nftHolderAttributes[nftTokenIdOfPlayer];

console.log("\nPlayer w/ character %s about to attack. Has %s HP and %s Heal Power", player.name, player.hp, player.healingPower);
console.log("Target %s has %s HP", target.name, target.hp, target.healingPower);

require (
player.hp > 0,
"Error: character must have more than 0 HP to heal target."
);

require (
target.hp > 0 ,
"Error: target must be alive to be healed"
);

if ((target.maxHp - target.hp) < player.healingPower) {
target.hp = target.maxHp;
} else {
target.hp = target.hp + player.healingPower;
}

console.log("Player attacked target. New target hp: %s", target.hp);
emit HealComplete(target.hp, player.hp);
}

function getNFTOnUser(address targetAddress) public view returns (CharacterAttributes memory) {
uint256 userNftTokenId = nftHolders[targetAddress];
if (userNftTokenId > 0) {
return nftHolderAttributes[userNftTokenId];
}
else {
CharacterAttributes memory emptyStruct;
return emptyStruct;
}
}

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

function getAllPlayers() public view returns (address[] memory) {
return allPlayers;
}

event CharacterNFTMinted(address sender, uint256 tokenId, uint256 characterIndex);
event AttackComplete(uint newTargetHp, uint newPlayerHp);
event HealComplete(uint newTargetHp, uint newPlayerHp);
}

Front End

I built the frontend in React. It was pretty straightforward by using the ethers package.

Key Learnings

  • Building applications in Web3 is not difficult, it’s just a different paradigm. A developer should spend more time learning about different protocols, capabilities, user cases, than the best libraries etc. to use.
  • Knowing what data to put on-chain and what to leave for off-chain. Transactions can take a few seconds to a few minutes to settle. Building a true real-time game with every action recorded on-chain is very difficult.
  • Minimizing gas fees. Gas fees are killer. Optimizations can be made to reduce the cost when committing a transaction. These add up over time.

--

--

Sukhpal Saini

Full Stack Engineer. Prev at IBM, Apple, Saks. Now building https://engyne.ai to help B2B SaaS get customers with content marketing using AI.