Building our first Mina zkApp game: Chapter 3

ZkNoid
7 min readAug 20, 2024

--

Welcome to the final part of our series on building a Mina zkApp game. In the previous articles, we laid the foundation by creating a basic number-guessing game and then improved its security by implementing a commit-reveal scheme. While these changes made the game more secure, we can still enhance the gameplay by adding an element of interactivity.

Today we’ll implement a “hot and cold” feature, where the hider can provide the guesser with hints about how close they are to the correct number — without revealing the number itself. This will not only make the game more engaging but also showcase the power of zk proofs in maintaining privacy while adding functionality.

Game rules

As before, our game involves two players: the hider and the guesser. The hider hides a number, and the guesser tries to determine what it is. The key difference in this version of the game is that the guesser has 5 attempts to guess the correct number. After each attempt, the hider will provide a clue, indicating whether the guessed number is greater than or less than the hidden number.

The twist is that these clues will be provided in a provable way, ensuring that neither the hider nor the guesser can cheat. This means that the hider must reveal the clue truthfully, and the guesser can trust that the clue is accurate, and can’t get any more information about hidden number, than what hider has provided.

So game user flow will look like:

To do so we will add two states to storage:

  @state(UInt64) guessLeft = State<UInt64>();
@state(UInt64) clue = State<UInt64>();

Hide method

The only thing that changes — we will add update of guess left field with default number of guesses:

export const DefaultGuessLeft = UInt64.from(5);

this.guessLeft.set(DefaultGuessLeft);

Guess method

Guess method is pretty much the same as in previous articles.

But we will add guess amount check and update it:

let curGuessLeft = this.guessLeft.getAndRequireEquals();
curGuessLeft.assertGreaterThan(UInt64.from(0), 'There is no more guesses');
this.guessLeft.set(curGuessLeft.sub(1));

Additionally, since the guess method can now be called multiple times within a single round, we need to ensure that the guesser stored on the zkApp is either 0 — indicating that no one has yet tried to guess the number — or that it matches the transaction sender.

 const guesserCheck = curGuesser
.equals(Field(0))
.or(curGuesser.equals(senderHash));
guesserCheck.assertTrue('Another user is guessing');

Check method

The main method of this game is check method. It will accept proof of clue from hidder, and store result of it on-chain.

Here how it will look like:

  @method async checkValue(checkProof: CheckProof) {
const curHiddenNumber = this.hiddenNumber.getAndRequireEquals();
const curGuessedNumber = this.guessedNumber.getAndRequireEquals();

curGuessedNumber.assertGreaterThan(Field(0), 'No guessed number');

checkProof.verify();
checkProof.publicInput.guessedNumber.assertEquals(curGuessedNumber);
checkProof.publicOutput.hiddenValueHash.assertEquals(curHiddenNumber);

this.clue.set(checkProof.publicOutput.clue);
this.guessedNumber.set(Field(0));
}

So we accept proof as an argument, and then check, that proof is valid:

checkProof.verify();

Additionally, we need to ensure that the public arguments of the proof match the values stored on-chain. In our case, these are the guessed number and the hiddenValue.

checkProof.publicInput.guessedNumber.assertEquals(curGuessedNumber);
checkProof.publicOutput.hiddenValueHash.assertEquals(curHiddenNumber);

The final step is to update the clue, allowing the guesser to use it, and reset the guessedNumber so that another number can be guessed in the next round.

this.clue.set(checkProof.publicOutput.clue);
this.guessedNumber.set(Field(0));

We haven’t yet described how we constructed out proof. Let’s do it now.

You can think of a zk proof as a black box with public inputs and outputs, as well as some private inputs. The guarantee provided by the proof is that the public outputs follow certain rules defined for that proof. While we don’t know the private inputs, we can trust the result produced by these private inputs.

Our proof will work as follows: it will take the guessed number as a public input, the hiddenValue as a private input, and output both the hash of the hidden value and the clue as public outputs. The proof will then compare the hiddenValue with the guessed value and return:

  • 1 if the hidden value is less than the guessed number,
  • 2 if they are equal, and
  • 3 if the hidden value is greater.

Additionally, the proof will compute the hash of the hidden value and include it as a public output. This way, the hidden value itself remains concealed, but we can still prove the accuracy of the clue.

For our proof we will crate separate file src/CheckProof.ts.

First lets create public input and output structures:

export class CheckProofPublicInput extends Struct({
guessedNumber: Field,
}) {}

export class CheckProofPublicOutput extends Struct({
result: UInt64,
hiddenValueHash: Field,
}) {}

Next, we need to define a ZkProgram. This program will process the inputs and return proofs. Our ZkProgram will have the public inputs and outputs as we defined earlier, and it will also include a single function, check, that will access the guessed number and hiddenValue and return the clue.

export const CheckProgramm = ZkProgram({
name: 'check-program',
publicInput: CheckProofPublicInput,
publicOutput: CheckProofPublicOutput,
methods: {
check: {
privateInputs: [HiddenValue],
async method(
input: CheckProofPublicInput,
hiddenValue: HiddenValue
): Promise<CheckProofPublicOutput> {
return check(input, hiddenValue);
},
},
},
});

The clue method will be straightforward: it will check whether the hidden value is greater or less than the guessed number and return a clue based on that. Additionally, it will compute the hash of the hidden value so that the zkApp can verify it without revealing the hidden value itself.

export const LESS = UInt64.from(1);
export const EQUALS = UInt64.from(2);
export const GREATER = UInt64.from(3);

export function check(
publicInput: CheckProofPublicInput,
hiddenValue: HiddenValue
): CheckProofPublicOutput {
const guessedNumber = publicInput.guessedNumber;
const hiddenGreater = hiddenValue.value.greaterThan(guessedNumber);
const hiddenLess = hiddenValue.value.lessThan(guessedNumber);
const hiddenEquals = hiddenValue.value.equals(guessedNumber);

const clue = Provable.switch(
[hiddenLess, hiddenEquals, hiddenGreater],
UInt64,
[LESS, EQUALS, GREATER]
);
const hiddenValueHash = hiddenValue.hash();

return new CheckProofPublicOutput({
clue,
hiddenValueHash,
});
}

And finally we will need to create CheckProof type from CheckProgramm, so we can use it inside our zkApp:

export class CheckProof extends ZkProgram.Proof(CheckProgramm) {}

Update method

Update method will be called either if all guess attempts is failed, or if guesser guessed the right value:

let curGuessLeft = this.guessLeft.getAndRequireEquals();
let currentHiddenNumber = this.hiddenNumber.getAndRequireEquals();
let curClue = this.clue.getAndRequireEquals();
let curScoreRoot = this.scoreRoot.getAndRequireEquals();

currentHiddenNumber.assertGreaterThan(Field(0), 'No hidden value');
curClue
.equals(EQUALS)
.or(curGuessLeft.equals(UInt64.zero))
.assertTrue('Conditions are not met for update score');

Then as in previous articles we will check score witness, and update it according to success of guesser. And finally we will reset game variables:

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

Full code can be found here — GitHub

Test

For test we will need to compute zk proof. We can easily do it as we would do in production:

await CheckProgramm.check(checkProgrammPublicInput, hiddenValue)

However, generating a real proof involves some processing time and requires compiling the CheckProgram first:

await CheckProgramm.compile()

Since this process can be time-consuming, we recommend using a mockProof for testing purposes. This approach will allow you to bypass the actual proof generation while still simulating the process:

import { Pickles } from 'o1js/dist/node/snarky';
import { dummyBase64Proof } from 'o1js/dist/node/lib/proof-system/zkprogram';

export async function mockProof<I, O, P>(
publicOutput: O,
ProofType: new ({
proof,
publicInput,
publicOutput,
maxProofsVerified,
}: {
proof: unknown;
publicInput: I;
publicOutput: any;
maxProofsVerified: 0 | 2 | 1;
}) => P,
publicInput: I
): Promise<P> {
const [, proof] = Pickles.proofOfBase64(await dummyBase64Proof(), 2);
return new ProofType({
proof: proof,
maxProofsVerified: 2,
publicInput,
publicOutput,
});
}

For this to work you will also have to update jest.config.js file, so jest will have access to o1js files, that is not exported by default:

moduleNameMapper: {
'o1js/dist/(.*)': '<rootDir>/node_modules/o1js/dist/$1',
'^(\\\\.{1,2}/.+)\\\\.js$': '$1',
},

Our test will consist of 3 parts:

  1. Test with guessed number less then hidden number
  2. Test with guessed number greater then hidden number
  3. Test with guessed number equal to hidden number

They produce same transaction, so we will overview only first case.

First, as before, we are hiding number:

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

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

Next we will guess number, that is less then hidden number:

    let guess1 = hiddenNumber.sub(1);
let tx3 = await Mina.transaction(senderAccount, async () => {
await zkApp.guessNumber(guess1);
});

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

Here comes part with proof generation. To use mockProof function we need public inputs and public outputs.

Public input is just hidden number:

    let checkProof1PublicInput = new CheckProofPublicInput({
guessedNumber: guess1,
});

Public output we can generate using same function, that CheckProgramm uses:

  let checkProof1PublicOutput = check(checkProof1PublicInput, hiddenValue);

And finally we can generate proof:

let checkProof1 = await mockProof(
checkProof1PublicOutput,
CheckProof,
checkProof1PublicInput
);

And send it to out zkApp:

    let tx4 = await Mina.transaction(senderAccount, async () => {
await zkApp.checkValue(checkProof1);
});

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

Then, lets check if our method works as expected, and that LESS is stored on clue

let curClue = zkApp.clue.get();
expect(curClue).toEqual(GREATER);

Then we will repeat all of this two more times, so we can check that it works fine in case of higher value and equal value.

Then, lets check if our method works as expected, and that LESS is stored on clue

let curClue = zkApp.clue.get();
expect(curClue).toEqual(GREATER);

Then we will repeat all of this two more times, so we can check that it works fine in case of higher value and equal value.

And finally after we guessed the right number we will call updateScore:

    const score = scoreMerkleMap.get(senderHash);
const scoreWitness = scoreMerkleMap.getWitness(senderHash);

let tx9 = await Mina.transaction(senderAccount, async () => {
await zkApp.updateScore(score, scoreWitness);
});

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

Conclusion

In this article, we’ve successfully enhanced our Mina zkApp game by adding a commit-reveal scheme and a clue mechanism that allows the hider to provide hints in a secure and verifiable way. By introducing zk proofs, we ensured that both the hider and the guesser can play the game without the risk of cheating, adding a layer of trust and complexity to our game.

Now, you have everything you need to build your first zkApp on Mina.

Check out our Medium for more articles on zkApp development, and feel free to reach out to us on Discord for any questions or further discussion.

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