Creating ChainBorn

Asbjorn Enge
ChainBorn
Published in
14 min readAug 5, 2022

Or how to build an NFT game on Tezos.

ChainBorn Banner by Jake Gumbleton

In this post, we lay out how we created ChainBorn. From smart contracts to dapp and index. All with code examples and links to relevant tools and software.

All the sample code and scripts used in the article can be found here.

No Tezos or blockchain prerequisites are required, but some software development experience is recommended.

Hope you enjoy the ride 🤓

ChainBorn

So we had this idea to create a TCG (tradable card game) akin to Pokémon or Magic the Gathering on Tezos. Blockchains, and especially NFTs, are very suitable for TCGs where you can have an NFT representing each issued card.

Since there already were lots of PFP collections on Tezos, we thought it would be fun to bring some additional utility to these. So we decided to make a “Hero” focused game for already existing PFP collections on Tezos ✨

ChainBorn was... born 😅 ⚔️

Smart Contract(s)

Let's jump right into it and start with the smart contracts governing the game.

To create a blockchain game you need a “smart contract” to control the game logic. You can think of your contract as a traditional game “backend” where you need to store the state and what logic is allowed on that state. Keep in mind though that contracts are public, and objects on the blockchain carry value, so you need to make extra sure you do not make logical mistakes; write lots of tests and keep the logic as simple as possible.

Write lots of tests and keep the logic as simple as possible

To create our contracts we used SmartPy — a very simple yet powerful smart contract development library that transpiles python code into Michelson (Tezos Smart Contract language), and much more 🙌

We like SmartPy because it’s very easy to read (it’s basically python), it has a solid testing story, and great tooling.

They also have a great online IDE, but if you are going to create an entire game you are going to need version control, code separation, test runners, etc. so better to install & work with the cli.

To make things a bit simpler I like to isolate a project in its own python virtualenv. Here is a script I use to init a SmartPy environment.

This gives me an isolated environment and the command spy that I can use to run SmartPy.

Separating concerns

Before we dive into the code, let’s consider how we best can structure our contracts.

In software development, it’s a mantra to separate concerns and have pieces that “do one thing well”. And smart contract development is no exception 😅

We like to think that when we put a smart contract on a blockchain it’s immutable (never changes), and that is true to some extent; an originated contract cannot be removed. But it can for sure have bugs and vulnerabilities that you want to fix. Or you might just also want to update the logic of your game a bit or introduce a new feature. So it makes sense to have the ability to update your logic somehow.

It makes sense to have the ability to update your logic.

There are mainly two ways to do this (afaik); updateable entrypoints and multiple contracts.

Updateable entrypoints mean that you store the logic for your entrypoints in the contract storage as lambdas (functions), and you have a separate entrypoint to update the logic. The upside is that you only have one contract to deal with. The downside is that you have a fixed set of entrypoints (at least afaik this is the case).

Multiple contracts mean you spread out the logic and/or data over multiple contracts making it possible to update an entire contract to modify some piece of the game. The upside here is you don’t have to deal with lambdas, and you can modify the entrypoints for a given contract. The downside is you have to deal with multiple contracts.

Another thing to consider is that putting data on the blockchain has a cost. You want to design your game in such a way that, if you need to change some logic, you don’t need to redeploy the entire game-state onto the chain.

Putting data on the blockchain has a cost.

So what we did for ChainBorn is that we have two contracts; the “logic” contract which we call the controller and the “data” contract which we call the datastore 😅 🤓 This way we can update our game logic by replacing the controller contract with a new one. The new contract can have new entrypoints, features, etc. but we get to keep the game state (datastore) around so we don’t have to deal with redeploying a huge state tree (costly!) when we want to update the logic.

(If we DO need to introduce some updates to the datastore, we can do this by adding a proxy contract between the controller and the original datastore, or adding another datastore contract, side-by-side with the old one, for the new data)

Joey agrees this is a good idea!

Types

Michelson is a strongly typed language, so you are typically going to be passing around lots of custom types. Your types typically grow as your game grows, but I usually have a basic idea of what I need up-front and start with that.

Create a file called types.py and start working out your types.

So we have a type called TGameConfig which is just what it says; it’s the game configuration parameters. Things like paused (indicates if the game is paused) and summon_cost (cost for summoning a Hero). You are typically going to refer to these things from different places in your game logic, so it makes sense to group them tn a type — at least I think so. It also makes it easier to have a single setConfig entrypoint instead of individual ones.

The more interesting THero represents a Hero character/card in ChainBorn. You can see it has some basic parameters like token_id , token_address , owner etc. but also a few more complex types like it’s character and attributes.

The THeroUid is also worth noting. It’s is a pair of an address and a nat. This relates to how NFT collections are structured on Tezos. A token is always minted on a unique contract ( address ) and each unique token has a token_id — so the THeroUid represents a unique NFT, or in other words a unique ChainBorn Hero. More on tokens later (FA2 section).

Datastore

The datastore contract will hold our game state. Before I overuse the word state, the “game state” is just the “game data” in a particular configuration 😅

We hope to never have to update the logic of the datastore because it is expensive to have to redeploy all its data. So we want to try and keep it as simple as possible.

You will notice that it has heroes and battles in its storage. These are the main parts of the game state.

The datastore will just have a few very basic entrypoints like set_hero to update a Hero. The logic for when and by who set_hero can be called is all stored within the controller contract, and it is always the controller that calls the datastore with an updated Hero.

Notice also the check_admin call within set_hero . This is to ensure only admins can call set_hero . So what we do is that we add the KT1... address of our controller contract as an admin in the datastore, and that way we ensure that only the controller can update the game state. If we add an updated controller, we just add that to admins , remove the old one, and we have updated the game logic 🎉 .

We also use the types from types.py that we created earlier.

Notice also that we use BigMap for both the heroes and battles . This is because we expect those maps to grow large, and in these cases, it’s best to use a BigMap.

Controller

The controller holds all our game logic and controls “how the game works” so to speak. It will typically have a bunch of more complex entrypoints.

We expect to update the controller now and then so we want it as “pure” as possible (no or very little state). In the storage, we have only a set of admins the config (TGameConfig as discussed earlier) and a set of collections (addresses to NFT collections supported by the game).

These are all easy to set via a few contract calls, should we ever have to update our controller contract, something we expect to have to do from time to time.

I have included the summon_hero entrypoint since it does a few interesting things we will discuss a bit later (FA2 section). Other entrypoints include challenge_hero , accept_challenge , execute_battle_turn and a bunch of other actions relevant to the game.

The controller contract is the contract users will interact with directly, and it’s the one we will be calling from our dapp discussed later.

Hacky McHackface

Tests

Tests are a very important topic in smart contract development. It is super useful to have a bunch of tests to ensure our logic is sound. SmartPy makes this really easy.

Let’s make a tests.py file.

First, we just import the different contracts we want to test and work with.

Next, we set some metadata variables. These will be used when we originate the different contracts in the init function.

I like to have an init function that I can call at the top of each of my tests to originate all the contracts I need. As you can see it takes the scene (test scenario) and an admin address as input, which is all we need to originate the required contracts.

The test() function is the actual test. Notice the kind="all" in the decorator. This can be used to run tests in isolation, which is super useful sometimes. As you can see we can now execute entrypoints on the contracts and verify contract storage against the scenario 🙌

To execute the tests we can do:

spy kind all tests.py output --html --stop-on-error

Remember spy is the SmartPy alias we set up earlier. Notice we specify the kind (of test we want to run). We use “all” to execute all tests, but if we set a custom kind in one of our test decorators, we can run only those tests and not all the others.

And we have a working test environment 🎉

Testing testing!

Compilation & Origination

When you think you are ready to test your game contracts on a testnet, you need to compile and originate them. First, we add a compile.py file to contain our “compilation targets”.

As you can see it’s mostly setting up the metadata and initial parameters we want to add to the contract storage. Initially, I just add my admin address everywhere, so I don’t have to coordinate so many things when originating, and I will update the different addresses and config later via an on-chain call to an entrypoint.

To compile these two targets:

spy compile compile.py compiled

Super easy ✨ Now our compiled Michelson code is available in the compiled folder.

To originate a contract (on ghostnet):

spy originate-contract --code compiled/game/step_000_cont_0_contract.tz --storage compiled/game/step_000_cont_0_storage.tz --rpc https://ghostnet.smartpy.io

If all goes well SmartPy will output something like:

[INFO] — Contract KT1JdE96ctFuCK5zgtM8tySXKwyH6K5THwuj originated!!!

That KT1… is your contract address. You can now copy that and head over to “Better Call Dev” and interact with your contract on-chain 😬

BCD is my crush.

Better Call Dev or BCD for short is an invaluable tool for developing on Tezos. You can see operations, storage and interact with your contract directly from an easy-to-use web UI 💖

Better Call Dev is an invaluable tool for developing on Tezos.

NOTE! SmartPy will handle origination cost when originating on a testnet (we used the new ghostnet — so should you probably). But when originating to mainnet you have to pass a --private-key that will be the originating address and pay for the origination.

FA2 Considerations

FA2 is a token standard on Tezos. Chances are you are going to be dealing with tokens in some form when creating a Tezos project, so it is a good idea to get familiar with the spec and workings of FA2 contracts.

Since ChainBorn is an NFT game and NFTs are FA2 contracts, we have to deal with FA2 contracts both for the game logic and for our indexer.

Staking

Let’s take staking as a prime example of working with FA2.

So in ChainBorn users can challenge each other and win or lose their Heroes. That includes the NFTs associated with the Hero. How can we ensure that the loser gives up the NFT to the winner? Well, to play ChainBorn we require that the user “stake” their NFT in ChainBorn. That means that they transfer ownership of the NFT to the ChainBorn contract.

The contract will, of course, allow a user to unstake their NFT, but only if certain criteria are met; Hero is not currently battling, or was not lost in a battle to someone else.

So we need to keep track of who owns the staked NFTs since we need to be able to know who can unstake them. If you look back to the Types section, notice that THero has a owner property. And if you take a look at the Controller we set owner=sp.sender (caller of the entrypoint) when a Hero is initially summoned. If a player loses this NFT in a battle, we swap the owner field to the winner, and now only the new owner can unstake the NFT 😉

But, when summoning a Hero, how can we ensure that the user transfers ownership of their NFT to our contract? In the summon_hero entrypoint , we have this “Transfer token to datastore” step, where we actually transfer the specified NFT from the sender to our datastore. But how can our contract do that since it does not own the NFT!? It is not allowed! This is where operators come into play. In the FA2 specification, user can list other addresses as operators, and thus allow them to manipulate a token on their behalf.

So, when a user wants to call summon_hero they actually have to create a batch of 3 transactions:

  • add_operator(game_controller)
  • summon_hero() // Now the controller can transfer the NFT
  • remove_operator(game_controller)

We will cover this more in the dapp section below. It is the dapp that forges these transactions. Users need to take care to see what operations they allow when interacting with any smart contract! Take care to check that remove_operator also is called. After the transfer, the controller does not need to be an operator to transfer the NFT back since he is now the owner.

Randomization

Games usually have some kind of randomization to determine outcomes. For ChainBorn the result of an attack should pick a random number (within the parameters of the Heroes attributes) to determine how much damage to deal to the opponent.

For this, we use the tezos-randomizer contract.

It is very important to keep in mind that every transaction on a blockchain is deterministic. Meaning if you know the input variables, you can predict the outcome. So even though the tezos-randomizer produces a random number that is not easy for a user to guess, it is totally possible for an attacker to simulate transactions to see the output of the randomizer. An attacker can then front-run (increase gas to get transactions placed first in a block) their transactions to get the outcome they want even if the “entropy” in the randomizer is modified.

Everything on a blockchain is deterministic.

There are ways to try to get around this problem, but they are notoriously hard to get right.

Well, if it’s impossible to do randomization on-chain, why not do it off-chain in some backend? Well, you can do that. But of course, someone could accuse you of cheating in your secret off-chain API 😅 🤷

So, what we decided to do for ChainBorn is a middle approach. We still use the randomizer, but we require users to sign a transaction (saying they want to execute their turn for battle X) and send that to our API. We in turn execute the transactions on-chain in no particular order and with the same gas limit. This way we still do the randomization on-chain (so we cannot cheat on our servers) and we remove the ability for users to font-run their transactions.

It has a cost for us to do this since we now have to pay for all the execute_battle_turn transactions. But we consider it a good tradeoff.

Uhm…

Dapp

The dapp is a “decentralized app” which usually just means an app that interacts with a blockchain 😅

We like web apps and we like React, so we built the ChainBorn dapp in React. Now I’m not going to cover setting up a React app in this tutorial, but let’s look at how we can interact with Tezos from a React app.

We are going to use the fantastic Taquito and Beacon libraries to achieve this.

I like to keep the wallet interactions in a wallet.js file:

I usually keep a wallet state in whatever state management library I’m using. So somewhere in your initial rendering path call getActiveAccount to check if a wallet is already connected (if a user reloads) and set the wallet state to its return value. If it returns null then no wallet is connected currently.

I also usually provide the RPC I want to use from my backend, so I also call setProvider somewhere in the initial rendering path after I have fetched this from the backend.

Use connectWallet and disconnectWallet accordingly. I like to use the tezos-wallet-component to handle my wallet UI needs.

So now that we have connected our wallet, let’s try to interact with the contract and summon a Hero.

You will notice here that we do the 3 required transactions discussed earlier in a batch operation. If all goes well we have now summoned a Hero in ChainBorn 🤯 🚀

Now we have a smart contract game and a dapp that can interact with it 😬

🤯🤯🤯

Index

The last missing piece of our game is an index.

We keep our game state on-chain, but how do we “extract” that state in a way that we can display it in our dapp?

We usually build an index to drive the dapp UI.

Now we could set up a Tezos node and query its RPC endpoints directly from the dapp, but that requires a lot of queries and data manipulation to get the data in a useful state.

Luckily the fine folks over at Baking Bad have created this wonderful tool called tzkt. Tzkt has a great API to query for contract data, and it’s totally possible to drive your app UI using tzkt directly.

Note! It is usually a good idea to set up your own RPC and tzkt instances so you don’t have to strain or rely on their public infrastructure.

Sometimes though, you want your data in a different format than tzkt offers. In this case, it becomes useful to create an “application-specific index”. Baking Bad has created a tool for this also; dipdup.

Personally, I found dipdup a bit cumbersome to configure and work with. I also really like Node.js, so I created a wrapper around the tzkt-api for node.

So using tzkt-api we pull the required data from the relevant contracts and populate a PostgreSQL database with the data.

And now we have a nice heroes table with fields like owner , token_address , token_id , wins , losses , experience etc. etc. 🎇

In front of our PostgreSQL database we can put Hasura to automagically create both a RESTful and a GraphQL interface 🤯 And we can query either of those from our dapp to drive the UI 😬 🚀

And that is the technical story behind ChainBorn.

ChainBorn banner by Jake Gumbleton

We hope you enjoyed this deep dive into Tezos game creation. And also that you will try the game when it's released — very very soon!

❤️ ⚔️ ❤️

--

--