This article series explores the game-building process using the updated ZkNoid framework. The framework allows games to be easily bootstrapped by plugging in all the required infrastructure parts. The game is automatically integrated into the ZkNoid games store and becomes a part of the provable ecosystem.
ZkNoid base concepts
The core element of ZkNoid framework is the github monorepo — link. Note: the link is updated from the previous guide. It contains the store, SDK and games implementations. Framework is divided into 4 core packages — @zknoid/sdk, @zknoid/games, @zknoid/chain-sdk, @zknoid/chain-games.
To start building with ZkNoid you need to clone the repo and launch the store:
# The updated store repo
git clone https://github.com/ZkNoid/store 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.
- apps/web (games store UI, launched for games)
- packages/sdk (react SDK)
- packages/games (games UI)
- packages/chain (appchain, launches the network)
- packages/chain-sdk (engine contracts)
- packages/chain-games (games contracts)
The web package implements the store with the games list that is launched on project start
The react sdk provides components, hooks, context and infrastructure to re-use in games
The games package contains the UI implementations of the games. This is the suggested entry point to start building your game
The chain package launches the appchain network with the games contracts. Appchain is based on protokit solution and can be considered to be a local blockchain with the predefined set of contracts
💡 A few words about appchains. App-chains are ZK rollups used in application that are deployed by the application team. Chain contains predefined contracts, identified by their names instead of addresses. Appchain proofs its execution correctness to the other network like Mina Protocol leveraging the high scalability of Zero Knowledge technologies
The chain-sdk package contains the engine contracts that allows to implement such features as multiplayer, cards engine, pseudorandom numbers generation
The chain-games package contains the contracts for the games. This is the second suggested entry point to start building your game
Web part file structure
Web parts of the games are in the packages/games directory. The folder represents the @zknoid/games package. It contains the following directories:
arkanoid
checkers
lottery
…
config.ts
Let’s check the packages/games/config.ts file. It registers all the games to be shown in the store:
import { createConfig } from "@zknoid/sdk/lib/createConfig";
import { numberGuessingConfig } from "./number_guessing/config";
import { randzuConfig } from "./randzu/config";
import { arkanoidConfig } from "./arkanoid/config";
import { checkersConfig } from "./checkers/config";
import { tileVilleConfig } from "./tileville/config";
import { pokerConfig } from "./poker/config";
import { thimblerigConfig } from "./thimblerig/config";
import { gameTemplateConfig } from "./game-template/config";
export const zkNoidConfig = createConfig({
games: [
// gameTemplateConfig,
tileVilleConfig,
randzuConfig,
checkersConfig,
thimblerigConfig,
pokerConfig,
arkanoidConfig,
numberGuessingConfig,
],
});
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 a predefined randzu game, packages/games/randzu/config.ts:
import { createZkNoidGameConfig } from "@zknoid/sdk/lib/createConfig";
import { ZkNoidGameType } from "@zknoid/sdk/lib/platform/game_types";
import { RandzuLogic } from "zknoid-chain-dev";
import Randzu from "./Randzu";
import {
ZkNoidGameFeature,
ZkNoidGameGenre,
} from "@zknoid/sdk/lib/platform/game_tags";
import RandzuLobby from "./components/RandzuLobby";
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",
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,
});
The game config defines the game info, such as the type, rules, and supported features. It also provides the game component that will be opened when the game is launched. ZkNoid supports lobbies and multiplayer, allowing you to register the lobby page here as well.
Chain part file structure
On the chain side, game directories are located here: packages/chain/src. All the game contracts should be stored in aseparate game directory and should be exported in the chain/src/index.ts file:
...
export * from './games/randzu/index.js';
export * from './games/checkers/index.js';
export * from './games/arkanoid/index.js';
export * from './games/thimblerig/index.js';
...
So the developer needs to create the game contracts directory in the chain package and export them to this file.
Our own game implementation
In this article we’ll implement the number-guessing game, the contracts for which we implemented in this article. The article is absolutely helpful if you have no idea how to implement contracts for Mina. This time the game will have a UI and will be integrated into the ZkNoid store.
Contracts part
We’ll start with adding contracts to the 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 a directory — packages/chain/src/games/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 were written for the Mina L1. While the protokit appchain has 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 code 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 scenes 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 consistency 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 hard, 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’ have hard assertions. If the 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 one to hide a number and make the 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 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 the frontend for the game we need to create the game’s directory on the frontend package: packages/games/number_guessing
The following file structure should be implemented:
packages/games/number_guessing/NumberGuessing.tsx
packages/games/number_guessing/config.ts
packages/games/number_guessing/assets/game-cover.svg
(could be taken here)
Let’s start with config. Here is an example config to put into the config file. It defines the basic game info, and registers runtime modules we set up before. Defines the game page component, NumberGuessing that is rendered when the game is opened
import { createZkNoidGameConfig } from "@zknoid/sdk/lib/createConfig";
import { ZkNoidGameType } from "@zknoid/sdk/lib/platform/game_types";
import { GuessGame } from "zknoid-chain-dev";
import {
ZkNoidGameFeature,
ZkNoidGameGenre,
} from "@zknoid/sdk/lib/platform/game_tags";
import NumberGuessing from "./NumberGuessing";
export const numberGuessingConfig = createZkNoidGameConfig({
id: "number-guessing",
type: ZkNoidGameType.PVP,
name: "Number guessing",
description: "Player hides a number. Other player tries to guess it",
image: "/image/games/soon.svg",
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 its 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 the guess is correct, the 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 a 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 display 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 uses the base GamePage component that should wrap every game page.
The full code for the component — link
The 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 the global web config, packages/games/config.ts:
import { createConfig } from "@zknoid/sdk/lib/createConfig";
import { arkanoidConfig } from "./arkanoid/config";
import { numberGuessingConfig } from "./number_guessing/config";
export const zkNoidConfig = createConfig({
games: [
...
arkanoidConfig,
numberGuessingConfig,
],
});
Now all is set for the game playing. Run the game store by the following command:
pnpm env:inmemory dev
The 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/store/blob/main/packages/games/config.ts
https://github.com/ZkNoid/store/tree/develop/packages/games/number_guessing
- Chain
https://github.com/ZkNoid/store/tree/main/packages/chain/src/games/number_guessing
https://github.com/ZkNoid/store/blob/main/packages/chain/src/runtime.ts
https://github.com/ZkNoid/store/blob/main/packages/chain/src/index.ts
Got questions, something doesn’t work?
Please consider checking our FAQ for developers. It answers a lot of common questions.
Feel free to ask any dev related questions in our Discord.
Conclusion
In the article, we implemented a simple game with a frontend for the ZkNoid game store. This is a simple example, allowing you to get used with the platform API. If you want to explore more infrastructure modules you could check the ready game examples or read more in the coming article series