Building our first Mina zkApp game: Chapter 1

ZkNoid
8 min readAug 13, 2024

--

Recently, Mina Protocol launched the long-awaited Berkeley update, bringing zkApps to the mainnet. This update opens up a vast array of opportunities for developers. While Mina zkApps differ significantly from traditional Solidity smart contracts, building on Mina is not as difficult as it might seem. Through these articles, we’ll demonstrate that you can easily start building games on Mina.

Numbers guessing game

In this tutorial, we will build a simple number-guessing game. One user hides a number, and another tries to guess it. We’ll also keep track of the number of correct guesses for each user.

We will break this game down into three levels of difficulty:

  1. Simple Version: The guessed number hash is stored on-chain. While it’s easy to guess, it serves as a good starting point.
  2. Secure Version: This version uses a commit-reveal scheme, making it much harder for the guessing user to discover the number.
  3. Hot and Cold Game: In this version, the hider provides hints on how close the guesser is to the correct number. To implement this, we’ll write a simple zk proof.

In this article, we’ll focus on building the simple version.

Prerequisites

For this tutorial you will need

  • NodeJS v18 or later
  • NPM v10 or later
  • git v2 or later

Also you will need zkApp-cli, which can be installed with the following command:

npm install -g zkapp-cli

Structs and storage structure

zkApps have only 8 slots of 256-bit storage, so we cannot store a large amount of data on-chain. However, this isn’t a significant issue because we can store most of our data off-chain and only store commitments to this data on-chain, enabling us to prove its validity later.

For out game we need to store:

  1. Hidden Number: This is the hash of the number. Since it’s a 256-bit value, we can easily store it on-chain.
  2. Score for Each User: This is a mapping from user addresses to the number of correctly guessed numbers. We cannot store this directly on-chain due to space limitations. Instead, we’ll store it as a Merkle Map, keeping only the root of the map on-chain.

So lets start building our game!

Preparing project

First, let’s create a project by running:

zk project GuessGame

A context menu will pop up and ask several questions.

Since we are only building the smart contract, select “none” for the UI:

? Create an accompanying UI project too? …
next
svelte
nuxt
empty
> none

If everything goes well, you should see the following:

✔ Create an accompanying UI project too? · none
✔ UI: Set up project
✔ Initialize Git repo
✔ Set up project
✔ NPM install
✔ NPM build contract
✔ Set project name
✔ Git init commit

Success!

Next steps:
cd GuessGame
git remote add origin <your-repo-url>
git push -u origin main

The default project includes some extra files that we won’t need, so let’s delete them:

cd GuessGame
rm src/Add.ts
rm src/Add.test.ts
rm src/interact.ts

Next, let’s create the files for our zkApp:

zk file src/GuessGame

This command will create two files: src/GuessGame.ts for our contract and src/GuessGame.test.ts for the tests.

You’ll also need to update the src/index.ts file. It currently points to the Add contract, so change it to point to GuessGame. The file should look like this:

import { GuessGame } from './GuessGame.js';

export { GuessGame };

Creating zkApp

Create a zkApp with a following code

import { Field, MerkleMap, SmartContract, State, state } from 'o1js';

export class GuessGame extends SmartContract {
@state(Field) hiddenNumber = State<Field>();
@state(Field) scoreRoot = State<Field>();

init() {
super.init();

this.scoreRoot.set(new MerkleMap().getRoot());
}
}

Every zkApp should extend the SmartContract class.

Storage slots are defined with @state descriptor.

The init method is called upon deployment. It allows us to configure initial storage values. In our game, we can leave hiddenNumber with its default value of 0, but we need to set the scoreRoot to an empty Merkle map root, so we can use later as the Merkle map commitment.

this.scoreRoot.set(new MerkleMap().getRoot());

Hiding the Value

Next, we need to define how the game is played. We’ll need two methods: one for the hider and another for the guesser.

The hider can update the value of hiddenNumber only if it is currently zero:

@method async hideNumber(number: Field) {
let curHiddenNumber = this.hiddenNumber.getAndRequireEquals();

curHiddenNumber.assertEquals(Field(0), 'Number is already hidden');

number.assertLessThan(Field(100), 'Value should be less then 100');

this.hiddenNumber.set(Poseidon.hash([number]));
}

We accept the number as input. In traditional EVM smart contracts, this could be a security issue since method inputs are public. However, in zkApps, all arguments are private, so this is not a concern.

First, we get the current value of hiddenNumber using getAndRequireEquals, ensuring it matches the value on the contract. If we used the get method instead, we would get an unconstrained value, which could lead to inconsistencies.

let curHiddenNumber = this.hiddenNumber.getAndRequireEquals();

Then, we check that this value is empty, so the hider cannot override it:

curHiddenNumber.assertEquals(Field(0), 'Number is already hidden');

We also add a sanity check to ensure the hider can pick only numbers within the 0–100 range:

number.assertLessThan(Field(100), 'Value should be less then 100');

Finally, after all checks pass, we update the value of hiddenNumber. Instead of storing the exact value, we store its hash. Although it's still relatively easy to break by hashing all values from 0 to 100, it adds some level of security:

this.hiddenNumber.set(Poseidon.hash([number]));

Guessing the Number

For the guesser, we need a function that checks whether the guessed value is correct, updates the user’s score, and resets the hiddenNumber to 0 so another value can be hidden.

@method async guessNumebr(
number: Field,
score: Field,
scoreWitness: MerkleMapWitness
) {
let curHiddenNumber = this.hiddenNumber.getAndRequireEquals();

curHiddenNumber.assertEquals(
Poseidon.hash([number]),
'Other numbre was guessed'
);

// Check witnessed value
const [prevScoreRoot, key] = scoreWitness.computeRootAndKeyV2(score);

this.scoreRoot
.getAndRequireEquals()
.assertEquals(prevScoreRoot, 'Wrong score witness');

const sender = this.sender.getAndRequireSignature();
const senderHash = Poseidon.hash(sender.toFields());
key.assertEquals(senderHash, 'Witness for wrong user');

const [newScoreRoot] = scoreWitness.computeRootAndKeyV2(score.add(1));

this.scoreRoot.set(newScoreRoot);
this.hiddenNumber.set(Field(0));
});

The arguments for this function might be a bit confusing. We accept the number being guessed, the score, and something called a Merkle Map Witness. The score represents the user’s score, and the witness is a proof that this value is stored in the tree.

Again, we retrieve the hidden number from the contract and check that this number matches the one that was hidden:

    let curHiddenNumber = this.hiddenNumber.getAndRequireEquals();

curHiddenNumber.assertEquals(
Poseidon.hash([number]),
'Other numbre was guessed'
);

Next, we need to check if the score for the current user is indeed in the Merkle Map. To do this, we compute the root and key using the function arguments:

const [prevScoreRoot, key] = scoreWitness.computeRootAndKeyV2(score);

Using these values, we check that prevScoreRoot matches scoreRoot, which is stored in the zkApp, and that the key is a hash of the sender.

    this.scoreRoot
.getAndRequireEquals()
.assertEquals(prevScoreRoot, 'Wrong score witness');

const sender = this.sender.getAndRequireSignature();
const senderHash = Poseidon.hash(sender.toFields());
key.assertEquals(senderHash, 'Witness for wrong user');

After all checks have passed, we can update the score Merkle Map and the hidden number.

this.scoreRoot.set(newScoreRoot);
this.hiddenNumber.set(Field(0));

Writing test

At this point we got our zkApp ready. Lets check, if it is working as expected.

First, we’ll take the test template from Add.test.ts, which is created automatically. While it's not mandatory to use this template, it is quite convenient because it provides several test accounts and an easy way to deploy the contract.

import { AccountUpdate, Mina, PrivateKey, PublicKey } from 'o1js';
import { GuessGame } from './GuessGame';

let proofsEnabled = false;

describe('Test', () => {
let deployerAccount: Mina.TestPublicKey,
deployerKey: PrivateKey,
senderAccount: Mina.TestPublicKey,
senderKey: PrivateKey,
zkAppAddress: PublicKey,
zkAppPrivateKey: PrivateKey,
zkApp: GuessGame;

beforeAll(async () => {
if (proofsEnabled) await GuessGame.compile();
});

beforeEach(async () => {
const Local = await Mina.LocalBlockchain({ proofsEnabled });
Mina.setActiveInstance(Local);
[deployerAccount, senderAccount] = Local.testAccounts;
deployerKey = deployerAccount.key;
senderKey = senderAccount.key;

zkAppPrivateKey = PrivateKey.random();
zkAppAddress = zkAppPrivateKey.toPublicKey();
zkApp = new GuessGame(zkAppAddress);
});

async function localDeploy() {
const txn = await Mina.transaction(deployerAccount, async () => {
AccountUpdate.fundNewAccount(deployerAccount);
await zkApp.deploy();
});
await txn.prove();
// this tx needs .sign(), because `deploy()` adds an account update that requires signature authorization
await txn.sign([deployerKey, zkAppPrivateKey]).send();
}

it('our test', async () => {
await localDeploy();
});
});

Our test will look like it:

it('our test', async () => {
await localDeploy();

const scoreMerkleMap = new MerkleMap();
const hiddenNumber = Field(9);
const wrongNumber = Field(5);
const senderHash = Poseidon.hash(senderAccount.toFields());

// Hide number
let tx = Mina.transaction(senderAccount, async () => {
await zkApp.hideNumber(hiddenNumber);
});

await tx.prove();
await tx.sign([senderKey]).send();

// Try to guess wrong number
let score = scoreMerkleMap.get(senderHash);
let scoreWitness = scoreMerkleMap.getWitness(senderHash);

await expect(async () => {
let tx2 = await Mina.transaction(senderAccount, async () => {
await zkApp.guessNumber(wrongNumber, score, scoreWitness);
});

await tx2.prove();
await tx2.sign([senderKey]).send();
}).rejects.toThrow('Other numbre was guessed');

let tx3 = await Mina.transaction(senderAccount, async () => {
await zkApp.guessNumber(hiddenNumber, score, scoreWitness);
});

await tx3.prove();
await tx3.sign([senderKey]).send();

scoreMerkleMap.set(senderHash, score.add(1));

// Check onchain values
let curHiddenValue = zkApp.hiddenNumber.get();
let curScoreRoot = zkApp.scoreRoot.get();

expect(curHiddenValue).toEqual(Field(0)); // It should be updated after right guess
expect(curScoreRoot).toEqual(scoreMerkleMap.getRoot());
});

So first we prepare and send hide transaction:

    let tx = Mina.transaction(senderAccount, async () => {
await zkApp.hideNumber(hiddenNumber);
});

await tx.prove();
await tx.sign([senderKey]).send();

Then we try to guess wrong number, but it predictably fails:

    await expect(async () => {
let tx2 = await Mina.transaction(senderAccount, async () => {
await zkApp.guessNumber(wrongNumber, score, scoreWitness);
});

await tx2.prove();
await tx2.sign([senderKey]).send();
}).rejects.toThrow('Other numbre was guessed');

And finally we send guess transaction with right numbers:

    let tx3 = await Mina.transaction(senderAccount, async () => {
await zkApp.guessNumber(hiddenNumber, score, scoreWitness);
});

await tx3.prove();
await tx3.sign([senderKey]).send();

Full code for this game can be found here — Guess-game(GitHub)

Deploying to devnet

After test is done, we can deploy our zkApp on devnet.

First we need to set deploy parameters: we will need to create deploy alias, set deploy endpoint and fee payer account.

zk config

Enter values to create a deploy alias:
? Create a name (can be anything): ‣ GuessDevnet

? Choose the target network: …
▸ Testnet
Mainnet
Custom network

? Set the Mina GraphQL API URL to deploy to: ‣ https://api.minascan.io/node/devnet/v1/graphql

? Set transaction fee to use when deploying (in MINA): ‣ 0.1

After it will ask what fee payer account to use for deploy. In case you already have one, you can chose it, otherwise:

▸ Use a different account (select to see options)

Recover fee payer account from an existing base58 private key
▸ Create a new fee payer key pair
NOTE: The private key is created on this computer and is stored in plain text.
Choose another saved fee payer

? Create an alias for this account ‣ deployer_acc

After it it will create public/private keypair on your pc.

But before you can deploy your zkapp, you should get testMina for your newly created account. You can do it here https://faucet.minaprotocol.com/.

After tokens arrive on your deployer account you can continue deployment of your app with

zk deploy guessdevnet

In case of one deploy alias and fee payer, it will use them. Otherwise it will ask you which one to use. After project built and keys is generated, it will ask:

Are you sure you want to send (yes/no)? · yes

After that it will give you hash of deploy transaction, so you can monitor status on https://minascan.io/devnet/home. Sometimes transaction can fail, so you should repeat previous steps multiple times.​

Conclusion

In this article, we demonstrated how to create your first zkApp game. However, the current version has several flaws, the most significant being a security issue — it’s quite easy to guess the number within the 0–100 range by knowing its hash. In the following articles, we will enhance our game to prevent the guesser from easily determining the hidden number. Additionally, we will make the game more engaging by incorporating zk proofs, allowing the hider to provide clues about the hidden number.

Follow ZkNoid in order not to miss the new articles:

Website | Docs | Twitter | Discord | Telegram | Medium

--

--

ZkNoid

Platform for games with provable game process based on Mina protocol and o1js. Docs – docs.zknoid.io. Github – github.com/ZkNoid. Twitter – https://x.com/ZkNoid