The NEAR blockchain is a perfect platform for building NFT games due to its low fees, high transaction speed, and scalability. In this article, we will explore the benefits of using crypto in gaming and how you can build your own NFT game on the NEAR blockchain. By doing so, not only will you create a fun and engaging experience for players but also learn valuable skills in developing on a cutting-edge platform.

Why create a TCG with NFTs?

Collectible trading card games have been a beloved hobby for decades. From the classic baseball cards of yesteryear to modern-day Magic: The Gathering and Pokémon card games, people all around the world enjoy collecting and playing with these small pieces of paper. However, traditional trading card games face several issues that have plagued them since their inception.

Counterfeiting has always been a major concern for collectors and players alike. With so much money at stake, it’s no wonder that unscrupulous individuals attempt to make fake cards to cash in on the market. The rise of blockchain technology offers new opportunities to combat this issue by providing secure, tamper-proof ways of verifying card authenticity.

Another problem is storage space. As collections grow larger and more valuable, they require more room for safekeeping. This can be a significant challenge for players who lack the necessary space or live in small apartments with limited storage options. Blockchain based non-fungible token (NFT) trading card games offer a solution by allowing collectors to keep their cards digitally without taking up physical space.

Finally, traditional trading card games require constant inventory management. Players must constantly purchase new packs of cards in hopes of finding valuable ones and trade with others to obtain missing pieces for their collection. With blockchain-based NFT TCGs, players can buy and sell individual cards on a secure marketplace without the hassle of managing physical inventory.

Why use NEAR?

Scalability

NEAR’s sharding design allows it to scale horizontally, meaning that as more validators join the network, transaction processing capacity increases. This scalable architecture enables developers to build applications without worrying about congestion or high fees.

Eco-friendly

Unlike other blockchains which consume a tremendous amount of energy, NEAR operates on the principle of proof-of-stake consensus mechanism that is ecologically sustainable. As an environmentally conscious developer, you can feel good knowing your application’s carbon footprint is minimal.

Native NFT Support

NEAR natively supports non-fungible tokens (NFTs), making it easy for developers to create and manage unique digital assets like artwork or collectibles. With the growing popularity of NFTs, this feature makes NEAR an ideal choice for anyone looking to build applications around these assets.

Hackathons

NEAR hosts regular hackathons where developers can showcase their skills, collaborate with others and win prizes. These events are a great way to learn about the platform, network with like-minded individuals, and potentially secure funding for your project.

BOS provides UI hosting for free

NEAR’s Blockchain Operating System (BOS) which offers developers a simple solution for deploying frontends on their blockchain. This service eliminates the need to manage servers or deal with complex infrastructure, allowing you to focus solely on building your application’s logic.

Building your assets with generative AI

For constructing our game assets, we use SDXL combined with several controlnets and ComfyUI. This process is detailed /learn/generating-assets-for-your-nft-game-with-sdxl-and-comfyui/

If you’d like to skip this section to learn the rest, feel free to use placeholders. If you have another way to create cards, go for it!

Please note that this process took several weeks with many iterations so plan accordingly.

Creating a Fungible Token to represent packs

https://github.com/martyn/near-monsters/blob/master/contracts-alpha/src/lib.rs

impl Contract {
    const CARDS_PER_PACK: U128 = U128(5);
    const TOTAL_SUPPLY: U128 = U128(25000);
    const NEAR_COST_PER_PACK: u128 = 4*ONE_NEAR;
    #[init]
    pub fn new_default_meta(owner_id: AccountId) -> Self {
      //...
    }

    #[init]
    pub fn new(owner_id: AccountId, metadata: FungibleTokenMetadata) -> Self {
      //...
    }

    #[payable]
    pub fn purchase(&mut self) {
        let buyer_id = env::predecessor_account_id();
        let buyer_deposit = env::attached_deposit();
        let num_packs = buyer_deposit / Self::NEAR_COST_PER_PACK;
        assert!(num_packs > 0, "You must purchase at least 1 pack at {} NEAR per pack.", Self::NEAR_COST_PER_PACK / ONE_NEAR);
        let refund_amount = buyer_deposit % Self::NEAR_COST_PER_PACK;
        let sender_id = env::current_account_id();
        log!("Sending {} packs from {} to {}", num_packs, buyer_id, sender_id);
        let amount: Balance = num_packs.into();
        let memo = format!("Purchase of {} MONSTER ALPHA packs for {} NEAR", num_packs, num_packs * Self::NEAR_COST_PER_PACK / ONE_NEAR);
        log!(memo);
        
        self.token.internal_transfer(&sender_id, &buyer_id, amount, Some(memo));
        log!("Sent {} packs with {} refund", num_packs, refund_amount);
        if refund_amount > 0 {
            Promise::new(buyer_id.clone())
                .transfer(refund_amount);
        }
    }

    #[payable]
    pub fn open_pack(&mut self) {
        let num_packs = U128(1);
        let sender_id = &env::predecessor_account_id();
        let receiver_id = AccountId::new_unchecked("system".into());
        let mint_gas = env::prepaid_gas() - Gas(100000000000000); //TODO

        let memo = "Open pack";
        self.token.internal_transfer(&sender_id, &receiver_id, num_packs.into(), Some(memo.into()));
        let mint_promise = env::promise_create(
            AccountId::new_unchecked(MONSTERS_NFT_CONTRACT.into()),
            "mint_random",
            &serde_json::to_vec(&(Self::CARDS_PER_PACK,sender_id)).unwrap(),
            10000000000000000000000,
            mint_gas,
        );
        env::promise_return(mint_promise)
    }
}

This is the fungible token. It handles purchasing and opening pack. The mint_random gets called in a cross-contract promise.

By transferring the pack to unchecked system, we burn the pack.

A quick warning, any errors that happen in mint_random will not refund the burnt pack.

Creating a mint_random that returns multiple NFTs

https://github.com/martyn/near-monsters/blob/master/contracts-nfts/src/lib.rs

impl Contract {
    #[init]
    pub fn new_default_meta(owner_id: AccountId) -> Self {
      //...
    }

    #[init]
    pub fn new(owner_id: AccountId, metadata: NFTContractMetadata) -> Self {
      //...
    }

    #[payable]
    pub fn mint_random(&mut self, amount: U128, token_owner_id: AccountId) -> Vec<Token> {
        assert_eq!(env::predecessor_account_id(), AccountId::new_unchecked(MONSTERS_ALPHA_CONTRACT.into()), "Unauthorized");
        (0..amount.into()).map(|index| {
            let i = index as usize;
            let roll = random_seed()[i*2] as usize;
            let cards = if roll < 8 {
                get_land_nft_cards()
            } else if roll < 35 {
                get_rare_nft_cards()
            } else if roll < 96 {
                get_uncommon_nft_cards()
            } else {
                get_common_nft_cards()
            };
            let card_index = ((random_seed()[i*2+1] as f64 / 256.0) * ((cards.len()-1) as f64)).round() as usize;
            let card = &cards[card_index];
            let card_count: u64 = match &self.copies_by_card_id {
                Some(copies) => {
                    copies.get(&card.id.to_string()).map_or(1, |count| count + 1)
                },
                None => 1,
            };
            let token_id = get_token_id(card.id, card_count);

            let token_metadata = TokenMetadata {
                title: None,
                description: None,
                media: None,
                media_hash: None,
                copies: None,
                issued_at: Some(get_current_datetime()),
                expires_at: None,
                starts_at: None,
                updated_at: None,
                extra: None,
                reference: None,
                reference_hash: None,
            };

            log!("Creating card with token_id {}", token_id);
            if let Some(copies_count) = &mut self.copies_by_card_id {
                copies_count.insert(&card.id.to_string(), &card_count);
            }
            self.tokens.internal_mint(token_id.into(), token_owner_id.clone(), Some(token_metadata))

        }).collect()
    }

    pub fn full_set_listing(&self) -> Vec<NFTCardTemplate> {
      //...
    }

    fn enrich_token_with_card_data(&self, token: &mut Token) {
      //...
    }

}

#[near_bindgen]
impl NonFungibleTokenCore for Contract {
    #[payable]
    fn nft_transfer(&mut self, receiver_id: AccountId, token_id: TokenId, approval_id: Option<u64>, memo: Option<String>) {
        self.tokens.nft_transfer(receiver_id, token_id, approval_id, memo);
    }

    #[payable]
    fn nft_transfer_call(&mut self, receiver_id: AccountId, token_id: TokenId, approval_id: Option<u64>, memo: Option<String>, msg: String) -> PromiseOrValue<bool> {
        self.tokens.nft_transfer_call(receiver_id, token_id, approval_id, memo, msg)
    }

    fn nft_token(&self, token_id: TokenId) -> Option<Token> {
        let original_token = self.tokens.nft_token(token_id);

        original_token.map(|mut token| {
            self.enrich_token_with_card_data(&mut token);
            token
        })
    }
}

mint_random is called by our fungible token burn. We select which card to mint using a 255 value random number. We validate the caller is our fungible token with:

assert_eq!(env::predecessor_account_id(), AccountId::new_unchecked(MONSTERS_ALPHA_CONTRACT.into()), "Unauthorized");

enrich_token_with_card_data allows us to modify the metadata of cards after they have been opened. This is important for future gameplay adjustments.

Opening packs on NEAR testnet with BOS

Now that the contracts exist, we need to interface with them. This is where BOS comes in. BOS hosts our frontend and interacts with our deployed smart contracts. This means there is no infrastructure costs and our UI can live on indefinitely.

https://github.com/martyn/near-monsters/tree/master/bos-frontend

This package includes everything you need to build and deploy to testnet. Some highlights:

opening packs https://github.com/martyn/near-monsters/blob/master/bos-frontend/src/openPack.jsx


const alphaPacksOwned = Near.view(ftContract, "ft_balance_of", {account_id: context.accountId});
const isOpenDisabled = (alphaPacksOwned === 0);
const nftsOwned = Near.view(nftContract, "nft_supply_for_owner", {account_id: context.accountId});
const nfts = Near.view(nftContract, "nft_tokens_for_owner", {account_id: context.accountId, limit:1000});//{from_index: (nftsOwned-5).toString(), limit:5});

...

const openPack = () => {
  try {
    Near.call(ftContract, 'open_pack', {}, 300000000000000, 10000000000000000000000);
  } catch (e) {
    State.update({error:`Error from NEAR: ${e.message}`});
  }
}

...

We verify that the user has alpha packs to burn then call openPack() on user request.

To setup the burn ‘system’ address, you need to use the CLI to add storage to it.

NEAR_ENV=mainnet near call monsters-alpha.near storage_deposit '{"account_id": "system"}' --accountId monsters-alpha.near --depositYocto 3000000000000000000000

purchasing packs https://github.com/martyn/near-monsters/tree/master/bos-frontend/src

...
const storageBalance = Near.view(ftContract, "storage_balance_of", {account_id: context.accountId});
const isRegistered = (storageBalance !== null);
...
const register = () => {
  try {
    // Perform smart contract call to buy packs
    Near.call(ftContract, 'storage_deposit', {}, Big("300000000000000"), ONE_NEAR/Big(100));
  } catch (e) {
    State.update({error:`Error from NEAR: ${e.message}`});
  }
}
const handleSubmit = () => {
  try {
    // Perform smart contract call to buy packs
    Near.call(ftContract, 'purchase', {}, Big("300000000000000"), state.packsToBuy*4*ONE_NEAR+300000000000000);
    State.update({error:null});
  } catch (e) {
    State.update({error:`Error from NEAR: ${e.message}`});
  }
};
...

This allows us to exchange NEAR for alpha packs. We also must register storage for our app before the purchase can take place.

Closing Thoughts

Creating a TCG NFT card game is an excellent way to learn about NEAR and playing games is a fun way for users to enter the NEAR ecosystem. The more fun everyone has, the easier it will be to attract new players, thereby expanding the user base of both the TCG NFT card game and the NEAR network. So let’s keep playing, creating, and learning as we build a thriving community around this innovative technology!

If you enjoyed this, our ALPHA packs are currently for sale. There are only 25,000 total and there won’t be reprints so grab some while you can!