Building a simple ZkNoid game from scratch, hacker’s guide

ZkNoid
14 min readAug 22, 2024

--

This article guide is still supported but outdated in favor of the guide of the updated store. You can find the new guide here.

Building ZK games contracts is a lot of fun. In the previous article series we explored building of a ZK game from scratch. However the game we developed haven’t got an important part making it a lot more enjoyable. The user interface. And ZKNoid SDK is here to help.

In this article series will explore the game building process using ZkNoid framework. The framework allows to easily bootstrap games by plugging in all the required infrastructure parts. The game is automatically integrated to the ZkNoid games store and becomes a part of the project ecosystem.

ZkNoid base concepts

The core of ZkNoid framework is the github repository — link. The repository is a monorepo containing the store, SDK and games. Framework is divided into two packages — web and chain.

To start building with ZkNoid you need to clone the repo and launch the store:

git clone https://github.com/ZkNoid/zknoid
cd zknoid

# ensures you have the right node js version
# !important! Without this step the app may not work!
nvm use

pnpm install
# Runs the game store
pnpm env:inmemory dev

Then you can open it on the http://localhost:3000/ address and see the store:

Let’s review the framework architecture

The web package contains both all the frontend infrastructure needed on the frontend side and the games

The chain package is an appchain based on protokit solution and represents the on-chain side of the games

💡 A few words about appchains. App-chains are ZK rollups that are deployed by the application team. They contain predefined contracts, identified by their names instead of addresses. Appchain proofs it’s execution correctness to the other network like Mina Protocol

The game implementation requires two steps. Game contracts building and UI implementation.

Every game has it’s own directory in the both packages.

Web part files structure

Web parts of the games are in the apps/web/games directory. It contains the following directories:

  • arkanoid
  • checkers
  • lottery

  • config.ts

Let’s check the apps/web/games/config.ts file. It registers all the games to be shown in the store:

import { createConfig } from '@/lib/createConfig';
import { arkanoidConfig } from './arkanoid/config';
import { randzuConfig } from './randzu/config';
...
import { lotteryConfig } from './lottery/config';

export const zkNoidConfig = createConfig({
games: [
arkanoidConfig,
randzuConfig,
...
lotteryConfig,
],
});

So, to list the game we need to make the game directory, implement the game config and register it. Here is a game config example for predefined randzu game, apps/web/games/randzu/config.ts:

import { createZkNoidGameConfig } from '@/lib/createConfig';
import { ZkNoidGameType } from '@/lib/platform/game_types';
import { RandzuLogic } from 'zknoid-chain-dev';
import Randzu from './Randzu';
import { ZkNoidGameFeature, ZkNoidGameGenre } from '@/lib/platform/game_tags';
import RandzuLobby from '@/games/randzu/components/RandzuLobby';
import { LogoMode } from '@/app/constants/games';

export const randzuConfig = createZkNoidGameConfig({
id: 'randzu',
type: ZkNoidGameType.PVP,
name: 'Randzu game',
description:
'Two players take turns placing pieces on the board attempting to create lines of 5 of their own color',
image: '/image/games/randzu.svg',
logoMode: LogoMode.CENTER,
genre: ZkNoidGameGenre.BoardGames,
features: [ZkNoidGameFeature.Multiplayer],
isReleased: true,
releaseDate: new Date(2024, 0, 1),
popularity: 50,
author: 'ZkNoid Team',
rules:
'Randzu is a game played on a 15x15 grid, similar to tic-tac-toe. Two players take turns placing their mark, using balls of different colors. The goal is to get five of your marks in a row, either horizontally, vertically or diagonally.',
runtimeModules: {
RandzuLogic,
},
page: Randzu,
lobby: RandzuLobby,
});

Game config defines the game info like type or rules, supported features. Also it provides the game component that will be opened when game is launched. ZkNoid supports lobbies and multiplayer, allowing to register the lobby page here as well.

Chain part files structure

On the chain side, game directories are located here: packages/chain/src. All the game contracts should be stored on the separate game directory and should be exported in the chain/src/index.ts file:

...
export * from './randzu/index.js';
export * from './checkers/index.js';
export * from './arkanoid/index.js';

So developer needs to create the game contracts directory in the chain package and export them in this file

Our own game implementation

In this article we’ll implement the number-guessing game, contracts for that we implemented in the previous article. The article is absolutely helpful if you have no idea how to implement the contracts for Mina. This time game will have the UI and will be integrated to ZkNoid store.

Contracts part

We’ll start with contracts adding into ZkNoid store. In the previous article we had the following smart contracts — GuessGame.ts

First we need to follow the framework structure. We need to create directory — packages/chain/src/number_guessing.

Then create there the following files: number_guessing/GuessGame.ts and number_guessing/index.ts.

GuessGame.ts will contain the game contracts and index.ts will export them.

We’ll copy the contract to the number_guessing/GuessGame.ts file

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

And the following to number_guessing/index.ts

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

Then we’ll export the GuessGame in the main index.ts file:

export { GuessGame } from './number_guessing';

Contracts migration from Mina to protokit

The contracts we used was written for the Mina L1. While protokit appchain has the similar contracts, there are some differences. Now we need to apply some changes in order to make the contracts work with ZkNoid. Here they are:

  • Runtime modules instead of smart contracts

The following definition

export class GuessGame extends SmartContract

Should be changed to

import { RuntimeModule, runtimeModule } from '@proto-kit/module';

interface NumberGuessingConfig {}
@runtimeModule()
export class GuessGame extends RuntimeModule<NumberGuessingConfig> {

This codes defines the appchain runtime module with config that allows to configure module on appchain init.

  • Runtime methods decorators

All the appchain module methods should be async and marked as @runtimeMethod :

@runtimeMethod()
public async hideNumber(number: UInt64) {
...
  • Moving merkle trees into statemaps

While Mina doesn’t support mappings and offers to use the merkle tree roots for dealing with key:value associations, protokit offers a more friendly way to deal with them: StateMaps. Also protokit has slightly different state initialization

The following code

@state(Field) hiddenNumber = State<Field>();
@state(Field) scoreRoot = State<Field>();

Should be changed into

import { State, StateMap, assert } from '@proto-kit/protocol';

@state() hiddenNumber = State.from<Field>(Field);
@state() scores = StateMap.from<PublicKey, UInt64>(PublicKey, UInt64);

In Mina contracts to set a key:value part to the merkle tree, we used to provide the withness for mapping root change and check all the values correctness:

// 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);

Instead of this, in protokit all this logic is hidden behind the scene thanks to the StateMap. Scores could be set like this:

await this.scores.set(sender, prevScores.value.add(1));
  • Removing getAndRequireEquals

Protokit solves the data consistencies issues and offers to call getter for the value receiving

let curHiddenNumber = this.hiddenNumber.getAndRequireEquals()let curHiddenNumber = await this.hiddenNumber.get();

  • Making getters and setters async

In protokit, getters and setters are promises that should be awaited:

  • Using soft assert

In protokit transactions should not fail hardly, because this may stop the appchain. The protokit assert function should be used for value assertions:

import { assert } from '@proto-kit/protocol';

curHiddenNumber.assertEquals(Field(0), 'Number is already hidden')assert(curHiddenNumber.value.equals(Field(0)), 'Number is already hidden')

  • Using protokit native number types

Types like UInt64 imported from ‘o1js’ has hard assertions. If number may overflow, it’s better to import the number type from protokit: import { UInt64 } from '@proto-kit/library'

Finalizing contracts part

After making the changes we should have the following content in number_guessing/GuessGame.ts:

import { RuntimeModule, runtimeModule } from '@proto-kit/module';
import {
Field,
Poseidon,
PublicKey,
UInt64,
} from 'o1js';

import { state, runtimeMethod } from '@proto-kit/module';
import { State, StateMap, assert } from '@proto-kit/protocol';
interface NumberGuessingConfig {}

@runtimeModule()
export class GuessGame extends RuntimeModule<NumberGuessingConfig> {
@state() hiddenNumber = State.from<Field>(Field);
@state() scores = StateMap.from<PublicKey, UInt64>(PublicKey, UInt64);
@runtimeMethod()
public async hideNumber(number: UInt64) {
let curHiddenNumber = await this.hiddenNumber.get();
assert(curHiddenNumber.value.equals(Field(0)), 'Number is already hidden');
assert(
curHiddenNumber.value.lessThan(Field(100)),
'Value should be less then 100',
);
await this.hiddenNumber.set(Poseidon.hash(number.toFields()));
}

@runtimeMethod()
async guessNumber(
number: UInt64,
) {
let curHiddenNumber = await this.hiddenNumber.get();
assert(
curHiddenNumber.value.equals(Poseidon.hash(number.toFields())),
'Other number was guessed',
);
const sender = this.transaction.sender.value;
let prevScores = await this.scores.get(sender);
await this.scores.set(sender, prevScores.value.add(1));
await this.hiddenNumber.set(Field(0));
}
}

The contract allows to hide a number and make other player guess it. Do not forget to export the contract and let’s move to the visual part of the game!

Registering appchain modules in the runtime.ts

To make runtime modules visible for the our sequencer, we need to register them in packages/chain/src/runtime.ts.

The following code should be added:

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

const modules = {
...,
GuessGame
};
const config: ModulesConfig<typeof modules> = {
...,
GuessGame: {}, // Our game config
};
export default {
modules,
config,
};

Frontend part

To implement frontend for the game we need to create game’s directory on the frontend package: apps/web/games/number_guessing

The following file structure should be implemented:

  • apps/web/games/number_guessing/NumberGuessing.tsx
  • apps/web/games/number_guessing/config.ts
  • apps/web/games/number_guessing/assets/game-cover.svg (could be taken here)

Let’s start with config. Here is an example config to put to the config file. It defines the basic game info, registers runtime modules we set up before. Defines the game page component, NumberGuessing that is rendered when game is opened

import { createZkNoidGameConfig } from '@/lib/createConfig';
import { ZkNoidGameType } from '@/lib/platform/game_types';
import { GuessGame } from 'zknoid-chain-dev';
import { ZkNoidGameFeature, ZkNoidGameGenre } from '@/lib/platform/game_tags';

import { LogoMode } from '@/app/constants/games';
import NumberGuessing from './NumberGuessing';

export const numberGuessingConfig = createZkNoidGameConfig({
id: 'number-guessing',
type: ZkNoidGameType.PVP,
name: 'Number guessing [single]',
description: 'Player hides a number. Other player tries to guess it',
image: '/image/games/soon.svg',
logoMode: LogoMode.CENTER,
genre: ZkNoidGameGenre.BoardGames,
features: [ZkNoidGameFeature.Multiplayer],
isReleased: true,
releaseDate: new Date(2024, 0, 1),
popularity: 50,
author: 'ZkNoid Team',
rules:
'Number guessing is a game where a player hides a number and gives the PC to another player. Other player tries to guess the number',
runtimeModules: {
GuessGame,
},
page: NumberGuessing,
});

Now let’s implement the game component. It defines all the game frontend logic in NumberGuessing.tsx.

First we need to implement the component with all the state variables we need to use

export default function NumberGuessing({
params,
}: {
params: { competitionId: string };
}) {
const [hiddenNumberHash, setHiddenNumberHash] = useState(0n);
const [userScore, setUserScore] = useState(0n);
const [inputNumber, setInputNumber] = useState(1);

Then we need a way to deal with the app-chain and network, wallet. ZkNoid forwards a special context allowing to receive the appchain clinet to access the on-chain data. Also it provides a set of useful stores

const networkStore = useNetworkStore();
const protokitChain = useProtokitChainStore();
const toasterStore = useToasterStore();

const client_ = client as ClientAppChain<
typeof numberGuessingConfig.runtimeModules,
any,
any,
any
>;
const query = networkStore.protokitClientStarted
? client_.query.runtime.GuessGame
: undefined;

Then we need to make the function for number hiding and guessing. Here they are:

const hideNumber = async (number: number) => {
const guessLogic = client_.runtime.resolve('GuessGame');

const tx = await client.transaction(
PublicKey.fromBase58(networkStore.address!),
async () => {
await guessLogic.hideNumber(UInt64.from(number));
}
);
await tx.sign();
await tx.send();
};

We’re finding the appchain contract by it’s name and sending a transaction.

Implementing the function for number guessing:

const guessNumber = async (number: number) => {
const guessLogic = client_.runtime.resolve('GuessGame');
const hash = Poseidon.hash(UInt64.from(number).toFields());
if (hash.equals(Field.from(hiddenNumberHash)).toBoolean()) {
toast.success(toasterStore, `Guessed correctly!`, true);
const tx = await client.transaction(
PublicKey.fromBase58(networkStore.address!),
async () => {
await guessLogic.guessNumber(UInt64.from(number));
}
);
await tx.sign();
await tx.send();
} else {
toast.error(toasterStore, `Guessed incorrectly!`, true);
}
};

It checks the guessed hash correctness and notifies about the results. If guess is correct, transaction is sent

Now we know how to send transactions. But we need to read data from blockchain as well. On-chain data can be changed only when new network block is produced. To watch it, the following useEffect hook can be used:

useEffect(() => {
query?.hiddenNumber.get().then((n) => {
const newHiddenNumberHash = n ? n.toBigInt() : 0n;
// Game state updated
if (newHiddenNumberHash != hiddenNumberHash) {
setInputNumber(0);
}
setHiddenNumberHash(newHiddenNumberHash);
});
if (networkStore.address) {
const userWallet = PublicKey.fromBase58(networkStore.address);
query?.scores.get(userWallet).then((n) => {
if (n) setUserScore(n.toBigInt());
});
}
}, [protokitChain.block]);

In the following code we’re fetching the hidden number hash and score from the appchain and setting them to the state.

Now we need the last part — the visual displaying for our game. The following code is used to render the game process:

Finalizing contracts part

After making the changes we should have the following content in number_guessing/GuessGame.ts:

import { RuntimeModule, runtimeModule } from '@proto-kit/module';
import {
Field,
Poseidon,
PublicKey,
UInt64,
} from 'o1js';

import { state, runtimeMethod } from '@proto-kit/module';
import { State, StateMap, assert } from '@proto-kit/protocol';
interface NumberGuessingConfig {}
@runtimeModule()
export class GuessGame extends RuntimeModule<NumberGuessingConfig> {
@state() hiddenNumber = State.from<Field>(Field);
@state() scores = StateMap.from<PublicKey, UInt64>(PublicKey, UInt64);
@runtimeMethod()
public async hideNumber(number: UInt64) {
let curHiddenNumber = await this.hiddenNumber.get();
assert(curHiddenNumber.value.equals(Field(0)), 'Number is already hidden');
assert(
curHiddenNumber.value.lessThan(Field(100)),
'Value should be less then 100',
);
await this.hiddenNumber.set(Poseidon.hash(number.toFields()));
}
@runtimeMethod()
async guessNumber(
number: UInt64,
) {
let curHiddenNumber = await this.hiddenNumber.get();
assert(
curHiddenNumber.value.equals(Poseidon.hash(number.toFields())),
'Other number was guessed',
);
const sender = this.transaction.sender.value;
let prevScores = await this.scores.get(sender);
await this.scores.set(sender, prevScores.value.add(1));
await this.hiddenNumber.set(Field(0));
}
}

The contract allows to hide a number and make other player guess it. Do not forget to export the contract and let’s move to the visual part of the game!

Registering appchain modules in the runtime.ts

To make runtime modules visible for the our sequencer, we need to register them in packages/chain/src/runtime.ts.

The following code should be added:

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

const modules = {
...,
GuessGame
};
const config: ModulesConfig<typeof modules> = {
...,
GuessGame: {}, // Our game config
};
export default {
modules,
config,
};

Frontend part

To implement frontend for the game we need to create game’s directory on the frontend package: apps/web/games/number_guessing

The following file structure should be implemented:

  • apps/web/games/number_guessing/NumberGuessing.tsx
  • apps/web/games/number_guessing/config.ts
  • apps/web/games/number_guessing/assets/game-cover.svg (could be taken here)

Let’s start with config. Here is an example config to put to the config file. It defines the basic game info, registers runtime modules we set up before. Defines the game page component, NumberGuessing that is rendered when game is opened

import { createZkNoidGameConfig } from '@/lib/createConfig';
import { ZkNoidGameType } from '@/lib/platform/game_types';
import { GuessGame } from 'zknoid-chain-dev';
import { ZkNoidGameFeature, ZkNoidGameGenre } from '@/lib/platform/game_tags';

import { LogoMode } from '@/app/constants/games';
import NumberGuessing from './NumberGuessing';
export const numberGuessingConfig = createZkNoidGameConfig({
id: 'number-guessing',
type: ZkNoidGameType.PVP,
name: 'Number guessing [single]',
description: 'Player hides a number. Other player tries to guess it',
image: '/image/games/soon.svg',
logoMode: LogoMode.CENTER,
genre: ZkNoidGameGenre.BoardGames,
features: [ZkNoidGameFeature.Multiplayer],
isReleased: true,
releaseDate: new Date(2024, 0, 1),
popularity: 50,
author: 'ZkNoid Team',
rules:
'Number guessing is a game where a player hides a number and gives the PC to another player. Other player tries to guess the number',
runtimeModules: {
GuessGame,
},
page: NumberGuessing,
});

Now let’s implement the game component. It defines all the game frontend logic in NumberGuessing.tsx.

First we need to implement the component with all the state variables we need to use

export default function NumberGuessing({
params,
}: {
params: { competitionId: string };
}) {
const [hiddenNumberHash, setHiddenNumberHash] = useState(0n);
const [userScore, setUserScore] = useState(0n);
const [inputNumber, setInputNumber] = useState(1);

Then we need a way to deal with the app-chain and network, wallet. ZkNoid forwards a special context allowing to receive the appchain clinet to access the on-chain data. Also it provides a set of useful stores

const networkStore = useNetworkStore();
const protokitChain = useProtokitChainStore();
const toasterStore = useToasterStore();

const client_ = client as ClientAppChain<
typeof numberGuessingConfig.runtimeModules,
any,
any,
any
>;
const query = networkStore.protokitClientStarted
? client_.query.runtime.GuessGame
: undefined;

Then we need to make the function for number hiding and guessing. Here they are:

const hideNumber = async (number: number) => {
const guessLogic = client_.runtime.resolve('GuessGame');
const tx = await client.transaction(
PublicKey.fromBase58(networkStore.address!),
async () => {
await guessLogic.hideNumber(UInt64.from(number));
}
);
await tx.sign();
await tx.send();
};

We’re finding the appchain contract by it’s name and sending a transaction.

Implementing the function for number guessing:

const guessNumber = async (number: number) => {
const guessLogic = client_.runtime.resolve('GuessGame');
const hash = Poseidon.hash(UInt64.from(number).toFields());
if (hash.equals(Field.from(hiddenNumberHash)).toBoolean()) {
toast.success(toasterStore, `Guessed correctly!`, true);
const tx = await client.transaction(
PublicKey.fromBase58(networkStore.address!),
async () => {
await guessLogic.guessNumber(UInt64.from(number));
}
);
await tx.sign();
await tx.send();
} else {
toast.error(toasterStore, `Guessed incorrectly!`, true);
}
};

It checks the guessed hash correctness and notifies about the results. If guess is correct, transaction is sent

Now we know how to send transactions. But we need to read data from blockchain as well. On-chain data can be changed only when new network block is produced. To watch it, the following useEffect hook can be used:

useEffect(() => {
query?.hiddenNumber.get().then((n) => {
const newHiddenNumberHash = n ? n.toBigInt() : 0n;
// Game state updated
if (newHiddenNumberHash != hiddenNumberHash) {
setInputNumber(0);
}
setHiddenNumberHash(newHiddenNumberHash);
});
if (networkStore.address) {
const userWallet = PublicKey.fromBase58(networkStore.address);
query?.scores.get(userWallet).then((n) => {
if (n) setUserScore(n.toBigInt());
});
}
}, [protokitChain.block]);

In the following code we’re fetching the hidden number hash and score from the appchain and setting them to the state.

Now we need the last part — the visual displaying for our game. The following code is used to render the game process:

return (
<GamePage
gameConfig={numberGuessingConfig}
image={CoverSVG}
mobileImage={CoverSVG}
defaultPage={'Game'}
>
<motion.div
className={
'flex grid-cols-4 flex-col-reverse gap-4 pt-10 lg:grid lg:pt-0'
}
animate={'windowed'}
>
<div className={'flex flex-col gap-4 lg:hidden'}>
<span className={'w-full text-headline-2 font-bold'}>Rules</span>
<span className={'font-plexsans text-buttons-menu font-normal'}>
{numberGuessingConfig.rules}
</span>
</div>
<div className={'hidden h-full w-full flex-col gap-4 lg:flex'}>
<div
className={
'flex w-full gap-2 font-plexsans text-[20px]/[20px] uppercase text-left-accent'
}
>
<span>Game status:</span>
<span>{hiddenNumberHash ? 'Guessing' : 'Hiding'}</span>
</div>
<span className="text-[20px]/[20px]">User score: {userScore}</span>
<div
className={
'flex w-full gap-2 font-plexsans text-[20px]/[20px] text-foreground'
}
>
<div className="flex flex-col gap-1">
{hiddenNumberHash ? (
<span>Guess the number:</span>
) : (
<span>Enter number to hide:</span>
)}
<input
type="number"
className="text-black"
value={inputNumber}
onChange={(v) => setInputNumber(parseInt(v.target.value))}
/>
<Button
label={hiddenNumberHash ? 'Guess number' : 'Hide number'}
onClick={() =>
hiddenNumberHash
? guessNumber(inputNumber)
: hideNumber(inputNumber)
}
/>
</div>
</div>
</div>
</motion.div>
</GamePage>
);

It use the base GamePage component that should wrap every game page.

The full code for the component — link

Game is ready to play, but not displayed in the store. Why? That’s because we need to list it. The following should be added to global web config, apps/web/games/config.ts:

import { createConfig } from '@/lib/createConfig';
import { arkanoidConfig } from './arkanoid/config';
import { numberGuessingConfig } from './number_guessing/config';

export const zkNoidConfig = createConfig({
games: [
arkanoidConfig,
...,
numberGuessingConfig // Game config is registered
],
});

Now all is set for the game playing. Run the game store by following command:

pnpm env:inmemory dev

Game store is opened on http://localhost:3000/ and we can see the game in the list

After clicking the game view is opened:

Tapping the hide number button should trigger the transaction window in the auro wallet.

Then we can see in the appchain console the transaction execution info:

zknoid-chain-dev:dev: [1] Produced block (0 txs)
zknoid-chain-dev:dev: [1] Transaction added to mempool: 10789382466340730935910077641930190324369135371813885725511969379075266001891 (1 transactions in mempool)
zknoid-chain-dev:dev: [1] Produced block (1 txs)
zknoid-chain-dev:dev: [1] ---------------------------------------
zknoid-chain-dev:dev: [1] Transaction #0
zknoid-chain-dev:dev: [1] Sender: B62qkhtqdz2j6S2ZEQ5SHKbL74RswtVHp4wdtyFBMtj2LbDHmmZ2yDK Nonce: 0n
zknoid-chain-dev:dev: [1] Method: GuessGame.hideNumber
zknoid-chain-dev:dev: [1]
zknoid-chain-dev:dev: [1] Arguments: {}
zknoid-chain-dev:dev: [1] Status: true
zknoid-chain-dev:dev: [1] ---------------------------------------
zknoid-chain-dev:dev: [1] Produced block (0 txs)

Now game state is changed and frontend state is changed:

We have our game working!

The full game example is integrated into ZkNoid store. For the full source code you could check:

  • Web:

https://github.com/ZkNoid/zknoid/blob/develop/apps/web/games/config.ts

https://github.com/ZkNoid/zknoid/tree/develop/apps/web/games/number_guessing

  • Chain:

https://github.com/ZkNoid/zknoid/blob/develop/packages/chain/src/index.ts

https://github.com/ZkNoid/zknoid/blob/develop/packages/chain/src/runtime.ts

Conclusion

In the article we implemented a simple game with frontend for ZkNoid games store. This is just a simple example, allowing to get used with the platform api. ZkNoid brings the following infrastructure that will be overviewed in the next articles

--

--

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