Documentation
👨‍💻 ZkNoid for developers
Guides

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.

packages/games/randzu2/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

packages/games/randzu2/components/RandzuPage.tsx
'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

packages/games/randzu2/components/RandzuPage.tsx
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

packages/games/randzu2/config.ts
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:

packages/games/randzu2/store/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

packages/games/randzu2/store/matchQueue.ts
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

packages/games/randzu2/components/RandzuPage.tsx
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

packages/games/randzu2/components/RandzuPage.tsx
 
  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

packages/games/randzu2/components/GameView.tsx
  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

packages/games/randzu2/components/RandzuPage.tsx
  ...
  // 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

packages/games/randzu2/components/RandzuPage.tsx
  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();
  }