Reimplementing Randzu game UI with ZkNoid SDK
Using ZkNoid infrastructure thrue the SDK allows to easily bootstrap a game. In this example we will explore the process of Randzu game creation and try to re-implement the randzu game UI. ZkGame consists of two parts: UI and on-chain program.
Get started
In future there will be an example game project generator by selected template
To create a game you need to clone the ZkNoid project repository and set up the environment
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
To launch the platform command pnpm dev
can be used
Then open project folder using your favorite code editor
cd ../
code zknoid
Games UI is stored inside packages/games
folder. There are already UI for arkanoid and randzu games.
Creating game config
Create a randzu2
directory inside packages/games
folder.
Create file named config.ts
.
// Base function that is used to create ZkNoid configs
import { createZkNoidGameConfig } from "@zknoid/sdk/lib/createConfig";
import { ZkNoidGameType } from "@zknoid/sdk/lib/platform/game_types";
// In this example we will focus on UI, and will use ready contracts
// Contracts implementation described in next article
import { RandzuLogic } from "zknoid-chain-dev";
export const randzu2Config =
createZkNoidGameConfig({
id: 'randzu2',
type: ZkNoidGameType.PVP,
name: 'Randzu2',
description: 'Two players take turns placing pieces on the board attempting to create lines of 5 of their own color',
image: '/randzu.jpeg',
runtimeModules: {
RandzuLogic
},
page: undefined,
});
This config stores game meta information used to display in store, runtime modules needed for game to interact with on-chain part and game page. We passed game page as undefined. Now we need to implement it and pass to the config.
Creating components
Now we need to create Next JS components of the game.
Create folder components
inside the game folder. Create component file named RandzuPage.tsx
inside
'use client'
import { useContext, useEffect, useState } from 'react';
enum GameState {
NotStarted,
MatchRegistration,
Matchmaking,
Active,
Won,
Lost,
}
export default function RandzuPage({
params,
}: {
params: { competitionId: string };
}) {
const [gameState, setGameState] = useState(GameState.NotStarted);
const competition = randzuCompetitions.find(
(x) => x.id == params.competitionId,
);
// Protokit AppChain client that allows to fetch data from blockchain or make transactions
const { client } = useContext(ZkNoidGameContext);
if (!client) {
throw Error('Context app chain client is not set');
}
...
return (<></>)
}
ZkNoid is build around competitions concept. Every game exists in some competitions context. Page receives the competition id, sets the game state. Fetches protokit appchain client automatically passed by the framework.
We need a function that will be called when game is launched. Let’s call this function start
.
This function should check if competition is a paid one. If so, call bridge to on-ramp user tokens to the app network.
Then it executed register
transaction that adds user to the match queue and registers his session keypair
import { useMinaBridge } from "@zknoid/sdk/lib/stores/protokitBalances";
import { PublicKey } from 'o1js';
import { useSessionKeyStore } from "@zknoid/sdk/lib/stores/sessionKeyStorage";
...
// Bridge allows to on-ramp tokens to the app network if not enough
const bridge = useMinaBridge();
// Sessions allow to make game transactions in background without user interruption
const sessionPublicKey = useStore(useSessionKeyStore, (state) => state.getSessionKey()).toPublicKey();
const sessionPrivateKey = useStore(useSessionKeyStore, (state) => state.getSessionKey());
// Manages network and wallet information
const networkStore = useNetworkStore();
// Function that will be called when user wants to start the game
const startGame = async () => {
// If competition is paid
if (competition!.enteringPrice > 0) {
// Bridging tokens to network if not enough
await bridge(competition?.enteringPrice! * 10 ** 9);
}
// Resolving protokit RandzuLogic runtime module.
// In protokit there are no addresses, modules are resolved using module names
const randzuLogic = client.runtime.resolve('RandzuLogic');
// Make transaction to register in the game queue from protokit client.
// Note, we register our session keypair in this call
const tx = await client.transaction(
PublicKey.fromBase58(networkStore.address!),
() => {
randzuLogic.register(sessionPublicKey, UInt64.from(Math.round(Date.now() / 1000)));
},
);
await tx.sign();
await tx.send();
// Setting state that we're waiting for matchmaking
setGameState(GameState.MatchRegistration);
};
Adding page component to the config
We left page field in the config as undefined
. Not it’s the time to register our page component in the config
import { createZkNoidGameConfig } from "@zknoid/sdk/lib/createConfig";
import { RandzuLogic } from "zknoid-chain-dev";
// Imported our new page
import RandzuPage from "./components/RandzuPage";
export const randzu2Config =
createZkNoidGameConfig({
id: 'randzu2',
name: 'Randzu2',
description: 'Two players take turns placing pieces on the board attempting to create lines of 5 of their own color',
image: '/randzu.jpeg',
runtimeModules: {
RandzuLogic
},
page: RandzuPage // Added our new page to the config
});
Implementing store
We need to fetch user info from the network to know whether user already in game, what is the current field and so one. For data fething we will implement store that will handle and update information once it’s changed.
Here you need to create stores
directory in the game folder and create file matchQueue.ts
:
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { PublicKey, UInt32, UInt64 } from "o1js";
import { useContext, useEffect } from "react";
import { useProtokitChainStore } from "../../../lib/stores/protokitChain";
import { useNetworkStore } from "../../../lib/stores/network";
import { RoundIdxUser, RandzuField } from "zknoid-chain-dev";
import ZkNoidGameContext from "@zknoid/sdk/lib/contexts/ZkNoidGameContext";
import { randzu2Config } from "../config";
import { ClientAppChain } from "@proto-kit/sdk";
...
// Here is our state managed by store
export interface MatchQueueState {
// Is user in loading state
loading: boolean;
// What is players queue length
queueLength: number;
// Is user in queue
inQueue: boolean;
// What is current user game id. Zero if user is not in a game
activeGameId: bigint;
// Current game info if user is in game
gameInfo: IGameInfo | undefined;
// Saved state after game end
lastGameState: 'win' | 'lost' | undefined;
// Players queue length
getQueueLength: () => number;
// Loads match queue. To call from observer. Note: runtime module types are resolved using our config here
loadMatchQueue: (client: ClientAppChain<typeof randzu2Config.runtimeModules>, blockHeight: number) => Promise<void>;
// Loads active game. To call from observer. Note: runtime module types are resolved using our config here
loadActiveGame: (client: ClientAppChain<typeof randzu2Config.runtimeModules>, blockHeight: number, address: PublicKey) => Promise<void>;
// Resets saved state after game end
resetLastGameState: () => void;
}
// Creating the store
export const useRandzuMatchQueueStore = create<
MatchQueueState,
[["zustand/immer", never]]
>(
immer((set) => ({
// Setting default values
loading: Boolean(false),
leaderboard: {},
queueLength: 0,
activeGameId: BigInt(0),
inQueue: Boolean(false),
gameInfo: undefined as IGameInfo | undefined,
lastGameState: undefined as 'win' | 'lost' | undefined,
resetLastGameState() {
set((state) => {
state.lastGameState = undefined;
state.gameInfo = undefined;
});
},
getQueueLength() {
return this.queueLength;
},
async loadMatchQueue(client: ClientAppChain<typeof randzu2Config.runtimeModules>, blockHeight: number) {
set((state) => {
state.loading = true;
});
// Fetching queue length from chain
const queueLength = await client.query.runtime.RandzuLogic.queueLength.get(
UInt64.from(blockHeight).div(PENDING_BLOCKS_NUM)
);
set((state) => {
state.queueLength = Number(queueLength?.toBigInt() || 0);
state.loading = false;
});
},
async loadActiveGame(client: ClientAppChain<typeof randzu2Config.runtimeModules>, blockHeight: number, address: PublicKey) {
set((state) => {
state.loading = true;
});
// Fetching active game id from chain
const activeGameId = await client.query.runtime.RandzuLogic.activeGameId.get(
address
);
// Fetching in queue state from chain
const inQueue = await client.query.runtime.RandzuLogic.queueRegisteredRoundUsers.get(
new RoundIdxUser({
roundId: UInt64.from(blockHeight).div(PENDING_BLOCKS_NUM),
userAddress: address
})
);
// If game ended but previous game info is saved in the store
if (activeGameId?.equals(UInt64.from(0)).toBoolean() && this.gameInfo?.gameId) {
const gameInfo = (await client.query.runtime.RandzuLogic.games.get(UInt64.from(this.gameInfo?.gameId!)))!;
const field = (gameInfo.field as RandzuField).value.map((x: UInt32[]) => x.map(x => x.toBigint()));
// Setting info about results in the ended game
set((state) => {
state.lastGameState = gameInfo.winner.equals(address).toBoolean() ? 'win' : 'lost';
state.gameInfo!.field = field;
state.gameInfo!.isCurrentUserMove = false;
})
}
// If user in active game, fetching game info
if (activeGameId?.greaterThan(UInt64.from(0)).toBoolean()) {
const gameInfo = (await client.query.runtime.RandzuLogic.games.get(activeGameId))!;
const currentUserIndex = address.equals(gameInfo.player1 as PublicKey).toBoolean() ? 0 : 1;
const player1 = gameInfo.player1 as PublicKey;
const player2 = gameInfo.player2 as PublicKey;
const field = (gameInfo.field as RandzuField).value.map((x: UInt32[]) => x.map(x => x.toBigint()));
set((state) => {
state.gameInfo = {
player1,
player2,
currentMoveUser: gameInfo.currentMoveUser as PublicKey,
field,
currentUserIndex,
isCurrentUserMove: (gameInfo.currentMoveUser as PublicKey).equals(address).toBoolean(),
opponent: currentUserIndex == 1 ? gameInfo.player1: gameInfo.player2,
gameId: activeGameId.toBigInt(),
winner: gameInfo.winner.equals(PublicKey.empty()).not().toBoolean() ? gameInfo.winner : undefined,
winWitness: {
x: 0,
y: 0,
directionX: 0,
directionY: 0
}
}
})
}
set((state) => {
state.activeGameId = activeGameId?.toBigInt() || 0n;
state.inQueue = inQueue?.toBoolean();
state.loading = false;
});
},
})),
);
Implementing store observer
Now we need fetch the info from chain and update it once new block is produced
import { useProtokitChainStore } from "@zknoid/sdk/lib/stores/protokitChain";
import { useNetworkStore } from "@zknoid/sdk/lib/stores/network";
import ZkNoidGameContext from "@zknoid/sdk/lib/contexts/ZkNoidGameContext";
export const useObserveRandzuMatchQueue = () => {
const chain = useProtokitChainStore();
const network = useNetworkStore();
const matchQueue = useRandzuMatchQueueStore();
const { client } = useContext(ZkNoidGameContext);
useEffect(() => {
// If wallet is not connected we can't know the current user and can't fetch the info
if (!network.walletConnected) {
return;
}
if (!client) {
throw Error('Context app chain client is not set');
}
// Updating information in the store
matchQueue.loadMatchQueue(client, parseInt(chain.block?.height ?? "0"));
matchQueue.loadActiveGame(client, parseInt(chain.block?.height ?? "0"), PublicKey.fromBase58(network.address!));
// Updating on each new block or when wallet is connected
}, [chain.block?.height, network.walletConnected]);
};
Using store in page component
Now we can use the store in the page component. At first we need to register the observer to enable store data fetching and updating. And use our store
import { useObserveRandzuMatchQueue, useRandzuMatchQueueStore } from '@zknoid/sdk/lib/stores/matchQueue';
...
// Enables on-chain data fetching and updating
useObserveRandzuMatchQueue();
const matchQueue = useRandzuMatchQueueStore();
Now we can update game state based on on-chain info
useEffect(() => {
if (matchQueue.inQueue && !matchQueue.activeGameId) {
setGameState(GameState.Matchmaking);
} else if (matchQueue.activeGameId) {
setGameState(GameState.Active);
} else {
if (matchQueue.lastGameState == 'win')
setGameState(GameState.Won);
if (matchQueue.lastGameState == 'lost')
setGameState(GameState.Lost);
}
}, [matchQueue.activeGameId, matchQueue.inQueue, matchQueue.lastGameState]);
Rendering game field based on store data
We have our game state and can drow the actual game field
import { IGameInfo } from '@zknoid/sdk/lib/stores/matchQueue';
interface IGameViewProps {
// Passing actual game state
gameInfo: IGameInfo | undefined;
// Callback function should be executed when cell is clicked
onCellClicked: (x: number, y: number) => void;
// Element that was chosen for move but not yet set in transaction position
loadingElement: {x: number, y: number} | undefined;
// Is transaction execution state
loading: boolean;
}
export const GameView = (props: IGameViewProps) => {
return (
<div className={`grid grid-cols-15 gap-1 ${
// If current user move, highlight the field
props.gameInfo?.isCurrentUserMove && !props.gameInfo?.winner && 'border-green-500 border-4 border-dashed'
} bg-gray-300 p-2`}>
{[...Array(15).keys()].map(i => (
[...Array(15).keys()].map(j =>
<div
className={`
bg-white ${
// If current user move and we're not awaiting transaction, hovering cells when pointner is on
props.gameInfo?.isCurrentUserMove && !props.loading && 'hover:bg-gray-200'
} w-7 h-7
bg-[length:30px_30px] bg-no-repeat bg-center p-5
${
// If current user move and cell is empty, show player's mark on hover
props.gameInfo?.isCurrentUserMove &&
props.gameInfo?.field?.[j]?.[i] == 0 &&
(!props.loading && (props.gameInfo?.currentUserIndex == 0 ?
"hover:bg-[url('/ball_red.png')]" :
"hover:bg-[url('/ball_blue.png')]"))
}
${
// Showing correct marks in cells
props.gameInfo?.field?.[j]?.[i] == 1 && "bg-[url('/ball_red.png')]"
}
${
// Showing correct marks in cells
props.gameInfo?.field?.[j]?.[i] == 2 && "bg-[url('/ball_blue.png')]"
}
${
// If user placed an element that is not yet confirmed in transaction, show it
props.loadingElement && props.loadingElement.x == i && props.loadingElement.y == j && (
props.gameInfo?.currentUserIndex == 0 ?
"bg-[url('/ball_red.png')] bg-opacity-50" :
"bg-[url('/ball_blue.png')] bg-opacity-50"
)}
`}
style={{ imageRendering: 'pixelated' }}
id={`${i}_${j}`}
onClick={() => props.onCellClicked(i, j)} // Calling callback on cell clicked
>
</div>
)
)
)}
</div>
);
};
Showing game field and round queue
We have all we need to show user he’s game in RandzuPage
...
// Showing game field
<GameView
gameInfo={matchQueue.gameInfo}
onCellClicked={onCellClicked}
loadingElement={loadingElement}
loading={loading}
/>
// Reading and showing match queue length from store
<div>Players in queue: {matchQueue.getQueueLength()}</div>
<div className="grow"></div>
<div className="flex flex-col gap-10">
<div>
Active competitions:
<div className="flex flex-col">
// Displaying game competitions
{randzuCompetitions.map((competition) => (
<Link
href={`/games/randzu/${competition.id}`}
key={competition.id}
>
{competition.name} – {competition.prizeFund} 🪙
</Link>
))}
</div>
</div>
</div>
Making game transactions
When cell is clicked we need to update the on-chain state. This transaction will be executed in onCellClicked
function.
We don’t want to interrupt user for transaction confirmation so we’ll use session key for transaction signing.
Transaction makeMove
is executed here updating game state and ending the game if win witness proves his win
const onCellClicked = async (x: number, y: number) => {
// If not current user or cell is not empty, do nothing
if (!matchQueue.gameInfo?.isCurrentUserMove) return;
if (matchQueue.gameInfo.field[x][y] != 0) return;
const currentUserId = matchQueue.gameInfo.currentUserIndex + 1;
// Preparing updated field
const updatedField = matchQueue.gameInfo.field.map(x => [...x]);
updatedField[y][x] = matchQueue.gameInfo.currentUserIndex + 1;
const randzuLogic = client.runtime.resolve('RandzuLogic');
const updatedRandzuField = RandzuField.from(updatedField);
// Preparing win witness that is checked in circuit and ends game if correct
const winWitness1 = updatedRandzuField.checkWin(currentUserId);
// Making makeMove transaction from session keypair with updated field and win witness
const tx = await client.transaction(
sessionPrivateKey.toPublicKey(),
() => {
randzuLogic.makeMove(
UInt64.from(matchQueue.gameInfo!.gameId),
updatedRandzuField,
winWitness1 ?? new WinWitness(
{
x: UInt32.from(0),
y: UInt32.from(0),
directionX: Int64.from(0),
directionY: Int64.from(0),
}
)
);
},
);
setLoading(true);
setLoadingElement({
x, y
});
// Extracting transaction, signing with session keypair and putting back
tx.transaction = tx.transaction?.sign(sessionPrivateKey);
// Sending transaction to the network
await tx.send();
}