Static Flow and Smart Contracts

The Game's General Flow

To highlight the benefits of implementing the SFT standard into Web3, we have built a scenario of an RPG mini-game. The logic of the game is that the player will have to fight different mystic creatures in order to advance, but also he could farm in order to mint some of the resources. Minting new items will be possible using at least one of the following methods: fighting, sleeping, acquisition, crafting and level-up. Providing functionality to each of these methods will be captured inside a single smart contract using six different public functions.

SFTs infrastructure

General

Web3 games need to host their items, characters and all kinds of assets that are owned by the player. Here are some smart contracts designed for specific games as their dynamics and needs are different:

• MMO Strategy - resources ( gold, silver, wood ) and buildings ( town hall level 9, tower of fire level 3, storage level 5 )

• RPG - resources (gold, stamina, health) and items (wood sword level 2, steel armor plate level 4)

• Card Game - resources (gold) and cards (every single card is another type of SFT with its attack, health, mana cost and other stats)

Our Game

We have designed our game to contain SFTs divided into two categories: resources and items. We have considered resources the intermediaries to purchasing or crafting, while items can be equipped and used in order to advance into the game's plot. Each item has power attributes depending on the level and the base material which it is built of.

Smart Contracts

As we have mentioned before, Web3 gaming raises the need of handling the entire process in a decentralized, transparent, and secure way. Creating the prerequisites for this fundamental need implies deploying smart contracts directly on the blockchain, and by this allowing the player to consult the smart contract/contracts which represent the basis of the game's functionality. Because of the fact that we know beforehand the whole flow of the game we are developing, we have only built a single smart contract that allows us to handle all the use cases. Next, we will breakdown the game's flow and the clarity functions we are using to operate the player's possible actions, described before.

Mint

Minting tokens to one user's address will be made using the main mint function:

(define-public (mint (token-id uint) (amount uint) (recipient principal))
  (begin
    (asserts! (is-eq tx-sender contract-owner) err-owner-only)
    (try! (ft-mint? semi-fungible-token amount recipient))
    (try! (tag-nft-token-id {token-id: token-id, owner: recipient}))
    (set-balance token-id (+ (get-balance-uint token-id recipient) amount) recipient)
    (map-set token-supplies token-id (+  (unwrap-panic (get-total-supply token-id)) amount))
    (print {type: "sft_mint", token-id: token-id, amount: amount, recipient: recipient})
    (ok true)))

A function to allow minting as a user is needed in order to handle the level-up, acquisition, and crafting operations triggered by the user. We don't want the function to be callable by every user, and for this, it is set as private. The mint-user function:

(define-private (mint-user (token-id uint) (amount uint) (recipient principal))
  (begin
    (try! (ft-mint? semi-fungible-token amount recipient))
    (try! (tag-nft-token-id {token-id: token-id, owner: recipient}))
    (set-balance token-id (+ (get-balance-uint token-id recipient) amount) recipient)
    (map-set token-supplies token-id (+  (unwrap-panic (get-total-supply token-id)) amount))
    (print {type: "sft_mint", token-id: token-id, amount: amount, recipient: recipient})
    (ok true)))

In order to perform mint actions allowed only for the admin, we have the following wrapper:

(define-private (mint-wrapper-admin (token-id uint) (amount uint) (recipient principal))
  (begin 
    (some (mint token-id amount recipient))
    recipient))

To facilitate minting rewards from different in-game operations such as fighting, we have built a wrapper that takes one tuple as an argument along with the recipient's address and performs the needed mint operation:

(define-private (mint-rewards (reward-tuple {resource-id: uint, resource-qty: uint}) (user principal)) 
  (mint-wrapper-admin (get resource-id reward-tuple) (get resource-qty reward-tuple) user))

Burn

Burning tokens from one user's address will be made using the main burn function:

(define-public (burn (token-id uint) (amount uint) (sender principal))
  (let
      ((sender-balance (get-balance-uint token-id sender)))
    (asserts! (or (is-eq sender tx-sender) (is-eq sender contract-caller)) err-invalid-sender)
    (asserts! (<= amount sender-balance) err-insufficient-balance)
    (try! (ft-burn? semi-fungible-token amount sender))
    (try! (tag-nft-token-id {token-id: token-id, owner: sender}))
    (set-balance token-id (- sender-balance amount) sender)
    (print {type: "sft_burn", token-id: token-id, amount: amount, sender: sender})
    (ok true)))

Burning resources from different in-game operations such as fighting implies building a wrapper similar to the one destinated for minting:

(define-public (burn-wrapper (burn-tuple {resource-id: uint, resource-qty: uint})) 
  (burn (get resource-id burn-tuple) (get resource-qty burn-tuple) tx-sender))

Transfer function

The clarity function that represents the ground for each of the three possible functionalities is the transfer one. Its structure is the following:

(define-public (transfer (token-id uint) (amount uint) (sender principal) (recipient principal))
  (let
    (
      (sender-balance (get-balance-uint token-id sender))
    )
    (asserts! (or (is-eq sender tx-sender) (is-eq sender contract-caller)) err-invalid-sender)
    (asserts! (<= amount sender-balance) err-insufficient-balance)
    (try! (ft-transfer? semi-fungible-token amount sender recipient))
    (try! (tag-nft-token-id {token-id: token-id, owner: sender}))
    (try! (tag-nft-token-id {token-id: token-id, owner: recipient}))
    (set-balance token-id (- sender-balance amount) sender)
    (set-balance token-id (+ (get-balance-uint token-id recipient) amount) recipient)
    (print {type: "sft_transfer", token-id: token-id, amount: amount, sender: sender, recipient: recipient})
    (ok true)
  )
)

The transfer function is responsible for transferring a given amount of a token from the sender address to the recipient one. Firstly, it creates a local variable called sender-balance which represents the balance of the certain token for the address which initiates the transfer. After that, it assures that the sender is valid and the amount of the sent token owned by the sender is enough to complete the transfer. As it can be observed, it calls another function named tag-nft-token-id. Its structure:

(define-private (tag-nft-token-id (nft-token-id {token-id: uint, owner: principal}))
  (begin
    (and
      (is-some (nft-get-owner? semi-fungible-token-id nft-token-id))
      (try! (nft-burn? semi-fungible-token-id nft-token-id (get owner nft-token-id)))
    )
    (nft-mint? semi-fungible-token-id nft-token-id (get owner nft-token-id))
  )
)

This function first checks if the owner of a certain token exists, and attempts to burn it for the given address. After that, it mints the token to the owner's address. Going back to the transfer function, after handling the burn-mint phase, it updates the balances of the given token for each of the sender and the recipient. If none of the conditionals negatively influence the execution thread, the transfer function finally returns an (ok true) and prints a brief confirmation message of the transaction that has just been initiated and sent to the blockchain.

Map-set

Here begins the stage where we establish the rules of the game. For every asset the player wants to obtain (by acquisition, crafting, or level-up), he will need a series of required resources. These can be either resources or items. For every single token that needs to be minted, we will store the tokens required in exchange using a dictionary for each of the three possible operations. All of these dictionaries will be found inside one single smart contract which will be deployed on-chain. Here's an example:

(define-map  
  { id: uint } 
  (list 100 { resource-id: uint, resource-qty: uint }))
  
(map-set acquisition-system 
  {id: u5} 
  (list {resource-id: u1, resource-qty: u15}))
  
(map-set acquisition-system 
  {id: u6} 
  (list 
    {resource-id: u1, resource-qty: u40} 
    {resource-id: u3, resource-qty: u7}))

(define-map  
  { id: uint } 
  (list 100 
    { resource-id: uint, resource-qty: uint }))
    
(map-set crafting-system 
  {id: u5} 
  (list 
    {resource-id: u3, resource-qty: u4}))
     
(map-set crafting-system 
  {id: u8} 
  (list 
    {resource-id: u4, resource-qty: u4}))

(define-map  
  { id: uint } 
  (list 100 
    { resource-id: uint, resource-qty: uint }))
    
(map-set level-up-system 
  {id: u6} 
  (list 
    {resource-id: u3, resource-qty: u6} 
    {resource-id: u5, resource-qty: u1} 
    {resource-id: u2, resource-qty: u2}))
(map-set level-up-system 
  {id: u7} 
  (list 
    {resource-id: u3, resource-qty: u10} 
    {resource-id: u6, resource-qty: u1} 
    {resource-id: u2, resource-qty: u3}))

(define-map fight-needed-resources 
  {fight-number: uint } 
  (list 100 
    { resource-id: uint, resource-qty: uint }))

(map-set fight-needed-resources 
  {fight-number: u1} 
  (list 
    {resource-id: u2, resource-qty: u10}))
    
(map-set fight-needed-resources 
  {fight-number: u2} 
  (list 
    {resource-id: u2, resource-qty: u12}))

(define-map fight-needed-resources 
  {fight-number: uint } 
  (list 100 
    { resource-id: uint, resource-qty: uint }))

(map-set fight-needed-resources 
  {fight-number: u1} 
  (list 
    {resource-id: u2, resource-qty: u10}))
    
(map-set fight-needed-resources 
  {fight-number: u2} 
  (list 
    {resource-id: u2, resource-qty: u12}))

(define-map fight-reward-system 
  { fight-number: uint } 
  (list 100 
    { resource-id: uint, resource-qty: uint }))

(map-set fight-reward-system 
  {fight-number: u5} 
  (list 
    {resource-id: u1, resource-qty: u300} 
    {resource-id: u7, resource-qty: u1}))


(define-map sleeping-reward-system 
  {sleeping-time: uint } 
  (list 100 
    { resource-id: uint, resource-qty: uint }))

(map-set sleeping-reward-system 
  {sleeping-time: u5} 
  (list 
    {resource-id: u2, resource-qty: u5}))
    
(map-set sleeping-reward-system 
  {sleeping-time: u10} 
  (list 
    {resource-id: u2, resource-qty: u15}))
    
(map-set sleeping-reward-system 
  {sleeping-time: u20} 
  (list 
    {resource-id: u2, resource-qty: u40}))

Map-get

After on-chain storing in a transparent way the rules of the game, we will want to create queries onto the already set needed tokens in order to perform every single in-game operation. These queries will be executed using map-get method inside a read-only function, like that:

(define-read-only (get-acquisition-resources (token-id uint))
    (let ((token-urr (map-get? acquisition-system {id: token-id})))
      (ok token-urr)))

(define-read-only (get-crafting-resources (token-id uint))
    (let ((token-urr (map-get? crafting-system {id: token-id})))
      (ok token-urr)))

(define-read-only (get-level-up-resources (token-id uint))
    (let ((token-urr (map-get? level-up-system {id: token-id})))
      (ok token-urr)))

(define-read-only (get-fight-needed-resources (fight-number uint))
  (let ((token-urr (map-get? fight-needed-resources {fight-number: fight-number})))
    (ok token-urr)))

(define-read-only (get-fight-rewards (fight-number uint))
  (let ((token-urr (map-get? fight-reward-system {fight-number: fight-number})))
    (ok token-urr)))

(define-read-only (get-sleeping-rewards (sleeping-time uint))
  (let ((token-urr (map-get? sleeping-reward-system {sleeping-time: sleeping-time})))
    (ok token-urr)))

We have created a read-only function for each use case the player can fit in. Next, we will split the game's thread in three scenarios.

The growth of the player into the game scenario

As we have set before all the dictionaries containing the requirements for minting new assets, along with the read-only functions to create queries onto them, we will need three functions, one for each use case.

Fighting System

The game's story mode will be represented by fighting different characters. The scenario the user gets through when fighting is a two-step one. The first step of the fighting system is the start-fight, which calls a function at the same time the user starts one of the given fights. The second step is met just in case the user wins the fight he has started beforehand and calls the rewarding function. The fighting system will be represented inside the Smart Contract by using two dictionaries, too. We have explained each one above, inside the Map-set chapter. The first dictionary, fight-needed-resources, will store a list of token ids and the amounts the user will burn to perform each fight. The function which handles the start-fight step is the following:

(define-public (start-fight (fight-number uint)) 
  (begin
    (asserts! (not (is-none (unwrap-panic (get-fight-needed-resources fight-number)))) err-not-some)
    (let  ((fight-resources-needed (unwrap-panic (get-fight-needed-resources fight-number)))
          (verified-ownership (fold and (map is-owned-needed (unwrap-panic fight-resources-needed)) true))) 
      (asserts! (is-some fight-resources-needed) err-not-some)
      (asserts! verified-ownership err-insufficient-balance)
      (some (map burn-wrapper (unwrap-panic fight-resources-needed)))
      (ok true))))

This function checks if the fight-needed-resources dictionary has records for the given fight number id. If it does, it creates two local variables: fight-resources-needed and verified ownership. The first one should be represented by a list of tuples containing the token-id of the resource needed and the amount of it, while the second one is responsible for checking whether the tx-sender owns every resource from the previously generated list. Then the function checks if fight-resources-needed is a response of type some, and not none. If not, it throws an error. After that, it verifies if the user that issued the transactions owns all the resources needed. If none of these two verification steps stop the function's execution thread, the function will finally burn the needed amount of every token for starting a certain fight.

The second dictionary, fight-reward-system will store a list of token ids and the amounts of them the user will mint after successfully performing and winning each one of the fights. The function which handles the rewarding step of the fighting system is the following:

(define-public (reward-fighting (fight-number uint) (user principal))
  (begin
    (asserts! (is-eq tx-sender contract-owner) err-owner-only)
    (asserts! (not (is-none (unwrap-panic (get-fight-rewards fight-number)))) err-not-some)
    (let  ((fighting-rewards (unwrap-panic (get-fight-rewards fight-number)))) 
      (asserts! (is-some fighting-rewards) err-not-some)
      (ok (fold mint-rewards (unwrap-panic fighting-rewards) user)))))

The reward-fighting function will be able to be called only by the admin. This is why the first step of it is to check if the tx-sender is the same as the contract owner. The rest of the function is almost the same as the start-fight function. The main difference is that if the execution thread is not interrupted by the verification steps, the function will reward the user, minting the resources won by the user to its address after successfully completing a story fight.

Sleeping

The sleeping operation will be the one that will allow the user to fill the energy bar. For this, we have built a dictionary to keep the reward system for certain "sleeping times". We have explained it before inside the Map-set chapter. The function which handles the sleeping operation is similar to the reward-fighting one. It is the following:

(define-public (reward-sleeping (sleeping-time uint) (user principal))
  (begin
    (asserts! (is-eq tx-sender contract-owner) err-owner-only)
    (asserts! (not (is-none (unwrap-panic (get-sleeping-rewards sleeping-time)))) err-not-some)
    (let  ((sleeping-rewards (unwrap-panic (get-sleeping-rewards sleeping-time)))) 
      (asserts! (is-some sleeping-rewards) err-not-some)
      (ok (fold mint-rewards (unwrap-panic sleeping-rewards) user)))))

Buying Assets -> Acquisition

The Acquisition represents the rawest way a player can mint a new asset. By acquisitioning new items, the player will only burn resources, not other owned items. For a balanced gameplay, only some of the items could be acquisitioned and the player will have to mix the other two use cases in order to successfully proceed. The function which will handle the Acquisition action looks like this:

(define-public (buy-item (id-new uint))
  (begin
    (asserts! (not (is-none (unwrap-panic (get-acquisition-resources id-new)))) err-not-some)
    (let ((acquisition-resources (unwrap-panic (get-acquisition-resources id-new)))
          (verified-ownership (fold and (map is-owned-needed (unwrap-panic acquisition-resources)) true)))
      (asserts! (is-some acquisition-resources) err-not-some)
      (asserts! verified-ownership err-insufficient-balance)
      (some (map burn-wrapper (unwrap-panic acquisition-resources)))
      (mint-user id-new u1 tx-sender))))

This function creates a local variable containing the resources needed for buying a new token and one for verifying if the tx-sender owns enough of the needed resources using is-owned-needed private function. After that, it ensures the token for which we perform the query has set resources inside the corresponding dictionary and the tx-sender owns enough of the needed tokens. After that it calls the burn-wrapper public function, followed by the mint-user function which is responsible for burning the given amount of the needed tokens for the user's address.

(define-private (is-owned-needed  (item {resource-id: uint, resource-qty: uint}))
  (>= (get-balance-uint (get resource-id item) tx-sender) (get resource-qty item)))
(define-public (transfer-wrapper (transfer-tuple {resource-id: uint, resource-qty: uint})) 
  (transfer (get resource-id transfer-tuple) (get resource-qty transfer-tuple) tx-sender 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AGli)

Finally, if the function's execution thread is not interrupted by the previous conditionals, it mints one new token to the recipient's address.

Crafting Assets -> Crafting

Crafting represents the way a player can construct a new asset. By crafting new items, the player will burn resources and items. The function which will handle the Crafting action looks like this:

(define-public (craft-item (id-new uint))
  (begin
    (asserts! (not (is-none (unwrap-panic (get-crafting-resources id-new)))) err-not-some)
    (let ((crafting-resources (unwrap-panic (get-crafting-resources id-new)))
          (verified-ownership (fold and (map is-owned-needed (unwrap-panic crafting-resources)) true)))
      (asserts! (is-some crafting-resources) err-not-some)
      (asserts! verified-ownership err-insufficient-balance)
      (some (map burn-wrapper (unwrap-panic crafting-resources)))
      (mint-user id-new u1 tx-sender))))

This function follows the flow of the Acquisition one because even if the in-game ways of obtaining a wanted token are very different, inside the smart contract they are almost the same.

Upgrading Assets -> Level-Up

The Level-Up represents the way a player can upgrade an already owned asset. By upgrading existing items, the player will burn resources and old items in order to obtain a new token. The function that handles the Level-Up action looks like this:

(define-public (level-up (id-new uint))
  (begin
    (asserts! (not (is-none (unwrap-panic (get-level-up-resources id-new)))) err-not-some)
    (let  ((level-up-resources (unwrap-panic (get-level-up-resources id-new)))
          (verified-ownership (fold and (map is-owned-needed (unwrap-panic level-up-resources)) true)))
      (asserts! (is-some level-up-resources) err-not-some)
      (asserts! verified-ownership err-insufficient-balance)
      (some (map burn-wrapper (unwrap-panic level-up-resources)))
      (mint-user id-new u1 tx-sender))))

This function follows the flow of the Acquisition and Crafting.

Rendering the User Interface to the Smart Contract Functionality

Set-Token-URI and Get-Token-URI

In order to enable the User Interface and render the assets images and attributes to the Front End we have to set, and then to be able to get one given token's URI. To allow this, we have created a token-uri dictionary. We will set for each token-id the corresponding JSON URL containing the token's attributes we have previously stored using a decentralized storage, in our case Pinata.

;; defining the dictionary
(define-map token-uri { id: uint } { url: (string-ascii 256) })
i
;; setting an URI for token-id 1
(map-set token-uri {id: u1} {url: "ipfs://QmcQzR4zcamVTzCPfCRBYywHVHGVncB2o3YpojvRmakVkC/1.png"})

;; defining the set public function
(define-public (set-token-uri (token-id uint) (token-url (string-ascii 256)))
  (begin 
    (asserts! (is-eq tx-sender contract-owner) err-owner-only)    
    (map-set token-uri {id: token-id} {url: token-url}) 
    (ok true)    
  )
)

;; defining the get read-only function
(define-read-only (get-token-uri (token-id uint))
    (let ((token-urr  (get url (map-get? token-uri {id: token-id}))))
      (ok token-urr)
    )
)

Last updated