Building the Frontend

Build the frontend for the game.

Most of the frontend is already set up for you. However, there are some key functions and constants missing from the frontend. Let's add those now.

First, let's install the frontend dependencies.

cd frontend
npm install

Next, update the frontend/src/utils/constants.ts variables with your deployed contract addresses.

Setting up SSO and Contract Interactions

In order to set up our onchain interactions and SSO wallet connection, create a new file in the frontend/src/utils folder called wagmi-config.ts:

touch src/utils/wagmi-config.ts

This file will provide all of the onchain functionality the app needs.

Before adding any functions, let's start by adding all of the imports needed at the top of the file:

frontend/src/utils/wagmi-config.ts
import {
  http,
  createConfig,
  connect,
  getAccount,
  writeContract,
  waitForTransactionReceipt,
  disconnect,
  readContract,
} from '@wagmi/core';
import {
  zksyncInMemoryNode,
  // zksyncSepoliaTestnet
} from '@wagmi/core/chains';
import { type Abi, parseEther } from 'viem';
import { zksyncSsoConnector, callPolicy } from 'zksync-sso/connector';
import { ABI, GAME_CONTRACT_ADDRESS, PAYMASTER_CONTRACT_ADDRESS } from './constants';
import { getGeneralPaymasterInput } from 'viem/zksync';
import type { Score } from './types';

Connectors

The first thing we need to set up is the ssoConnector:

frontend/src/utils/wagmi-config.ts
const ssoConnector = zksyncSsoConnector({
  authServerUrl: 'http://localhost:3002/confirm',
  session: {
    expiry: '1 hour',
    feeLimit: parseEther('0.01'),
    contractCalls: [
      callPolicy({
        address: GAME_CONTRACT_ADDRESS,
        abi: ABI as Abi,
        functionName: 'verifyProof',
      }),
    ],
  },
});

This connector configures:

  • The authServerUrl that the wallet will connect to, as we will be running this locally.
  • The session time expiry limit.
  • The maximum amount of transaction fees that can be used in the session.
  • That the session can only call the verifyProof function in our game contract.

Next, we need to define the wagmi configuration object.

frontend/src/utils/wagmi-config.ts
export const config = createConfig({
  chains: [
    // zksyncSepoliaTestnet,
    zksyncInMemoryNode,
  ],
  connectors: [ssoConnector],
  transports: {
    // [zksyncSepoliaTestnet.id]: http(),
    [zksyncInMemoryNode.id]: http(),
  },
});

This object configures the chains and wallet connectors available for the user. If you want to configure the network to ZKsync Sepolia Testnet, or use another wallet connector, this is where you can make those changes.

connectWithSSO

Now that we have the wagmi config object and SSO connector, we can make a new function called connectWithSSO for the user to connect.

frontend/src/utils/wagmi-config.ts
export const connectWithSSO = () => {
  connect(config, {
    connector: ssoConnector,
    chainId: config.chains[0].id,
  });
};

disconnectWallet

To let the user disconnect, let's add another function called disconnectWallet.

frontend/src/utils/wagmi-config.ts
export const disconnectWallet = async () => {
  await disconnect(config);
};

Reading the Contract

To read the top score for a given player and the top 10 high scores for game overall, add the getPlayerHighScore and getHighScores functions below:

frontend/src/utils/wagmi-config.ts
export async function getPlayerHighScore(playerAddress: `0x${string}`) {
  const data = await readContract(config, {
    abi: ABI,
    address: GAME_CONTRACT_ADDRESS,
    functionName: 'getPlayerScore',
    args: [playerAddress],
  });
  return data;
}

export async function getHighScores() {
  const highScores: Score[] = [];
  const data = await readContract(config, {
    abi: ABI,
    address: GAME_CONTRACT_ADDRESS,
    functionName: 'getTopScores',
  });

  if (Array.isArray(data)) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const typedData: Score[] = data as any;

    typedData.forEach((score) => {
      const newHighScore: Score = {
        player: score.player,
        blocksDestroyed: score.blocksDestroyed,
        timeElapsed: score.timeElapsed,
      };
      highScores.push(newHighScore);
    });
  }

  if (highScores.length < 10) {
    for (let i = highScores.length; i < 10; i++) {
      highScores.push({
        player: '0x0000000000000000000000000000000000000000',
        blocksDestroyed: 0n,
        timeElapsed: 0n,
      });
    }
  }

  return highScores;
}

Writing to the Contract

Finally, we can add a function to allow users to write to the contract with their high scores.

frontend/src/utils/wagmi-config.ts
export async function verifyProof(publicValues: string, proofBytes: string) {
  if (!publicValues || !proofBytes) {
    alert('Proof data not found');
    return;
  }

  const account = getAccount(config);
  if (!account.address) {
    alert('Account not found');
    return;
  }

  const txData = {
    from: account.address,
    abi: ABI,
    address: GAME_CONTRACT_ADDRESS,
    functionName: 'verifyProof',
    args: [publicValues, proofBytes],
    paymaster: PAYMASTER_CONTRACT_ADDRESS,
    paymasterInput: getGeneralPaymasterInput({ innerInput: '0x' }),
    chainId: config.chains[0].id,
    connector: config.connectors[0],
  };

  const txHash = await writeContract(config, txData);

  const transactionReceipt = await waitForTransactionReceipt(config, {
    hash: txHash,
  });
  return transactionReceipt.status === 'success';
}

The verifyProof function:

  • Calls the corresponding function in the game contract.
  • Passes the publicValues and proofBytes arguments into the contract function.
  • Uses the paymaster contract deployed earlier to pay for the transaction so the user doesn't need any funds to play.
  • Finally returns true or false depending on whether the transaction succeeded.

Generating Proofs

Make a new file in the same folder called proofs.ts:

touch src/utils/proofs.ts

In the backend folder, there is an API already set up to handle requests to create a proof using the ELF binary produced by the SP1 program. Instead of using mock game data like we did in the script, we can send the actual user's recorded game actions and final score to the API to create a proof.

Let's add a function called submitProof to do this:

frontend/src/utils/proofs.ts
import { HTTP_API_URL, WS_API_URL } from './constants';
import type { Action } from './types';

export async function submitProof(actionLog: Action[], blocksDestroyed: number, timeElapsed: number) {
  try {
    if (!blocksDestroyed || !timeElapsed || actionLog.length === 0) {
      alert('Invalid proof data');
      return;
    }

    const jobID = await requestProof(actionLog, blocksDestroyed, timeElapsed);
    if (!jobID) {
      return;
    }
    const result = await getProofResult(jobID);
    return result.proof_data;
  } catch (error) {
    console.error('Error creating proof:', error);
  }
}

This function:

  • Takes three arguments: the recorded actionLog, the total blocksDestroyed by the player, and the timeElapsed according to the game library.
  • Sends the arguments to the API and receives back a jobID.
  • Waits until the proof is ready, and uses the jobID to get the final proofData.
  • Returns the final proofData object, which contains both the publicValues and proofBytes hash strings required for the verifyProof function added earlier.

Next, add the remaining helper functions below.

frontend/src/utils/proofs.ts
async function requestProof(actionLog: Action[], blocksDestroyed: number, timeElapsed: number) {
  try {
    const inputData = JSON.stringify({
      action_log: actionLog,
      blocks_destroyed: blocksDestroyed,
      time_elapsed: timeElapsed,
    });
    const response = await fetch(`${HTTP_API_URL}/prove`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: inputData,
    });
    const { job_id: jobID } = await response.json();
    return jobID;
  } catch (error) {
    console.error('Error requesting proof:', error);
  }
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function getProofResult(jobID: string): Promise<any> {
  return new Promise((resolve, reject) => {
    const wsEndpoint = `${WS_API_URL}/proof/${jobID}`;
    const socket = new WebSocket(wsEndpoint);

    socket.addEventListener('message', (event) => {
      const data = JSON.parse(event.data);
      if (data.status === 'processing') {
        console.log('Proof is being processed...');
      } else if (data.status === 'complete') {
        socket.close();
        resolve(data);
      } else if (data.status === 'failed' || data.status === 'not_found') {
        reject(new Error('Proof generation failed'));
      }
    });

    socket.addEventListener('error', (error) => {
      console.error('WebSocket error:', error);
      socket.close();
      reject(error);
    });

    setTimeout(() => {
      socket.close();
      reject(new Error('WebSocket connection timed out'));
    }, 5 * 60000); // 5 min timeout
  });
}

Running the App

Follow the steps below to test and run the full app locally.

  1. To test with ZKsync SSO locally, you will need to clone the zksync-sso repository, and follow the steps in the README to launch the example demo app and local auth server. Make sure the auth server is running at the same URL configured in the ssoConnector.
  2. Launch the API service to generate proofs using your SP1 private key. Move back into the root folder, and run the command below. It will run at the API your local 8000 port.
    cd ..
    SP1_PROVER=network NETWORK_RPC_URL=https://rpc.production.succinct.xyz NETWORK_PRIVATE_KEY=<YOUR_PRIVATE_KEY> RUST_LOG=info cargo run -p api --release
    
  3. Build and compile the game for web. This will create a wasm folder inside the frontend folder. If you don't have wasm-bindgen installed already, run the first command below to install it.
    cargo install -f wasm-bindgen-cli
    

    Then open a new terminal in the root folder of the project, and run the build command below.
    cargo build -p web-game --release --target wasm32-unknown-unknown && wasm-bindgen --out-name game_wasm --out-dir frontend/wasm --target web target/wasm32-unknown-unknown/release/web_game.wasm
    
  4. Finally, you can run the frontend app from the frontend folder:
    cd frontend
    npm run dev
    

Now that everything is set up, you can finally test out the app! It should have opened for you, but if not, you can find it running at http://localhost:5173/.

All icons are configured to open using a double-click. Double click on the "High Scores" icon to see the score submitted from the interact script.

To play the game, first open the "Log In" button to log in with SSO. Then, double click on the "Games" icon to open the games folder. Double click again on the "Brickles" game to open the game window.

Click the text at the top to start the game, and use the arrow keys on your keyboard to move the paddle left or right.

Once you get a score you are happy with, you can click the "SAVE ON CHAIN" text to generate a proof of your recorded movements, score, and time.

Then, using the session you created when signing in, the app will submit the proof data to the game contract to verify your score onchain.

Once completed, you can open the "High Scores" window to view your new high score in the top 10 ranking.

Next Steps

Ready to build more ZK games? You can replace the game_lib with any other deterministic game written in Rust!


Made with ❤️ by the ZKsync Community