DEV Community

Ahmed Castro for Filosofía Código EN

Posted on • Updated on

ZK 🤝 Autonomous Worlds: Guide on Creating Privacy On-Chain Games

Zero Knowledge solves the problem of any type of game requiring privacy to be fully played on-chain. For example playing poker on-chain by enabling players to keep their hands private.

In this tutorial, we'll implement Minesweeper on-chain. A Game Master will hide bombs on a map, and if a player steps on one, the Game Master can prove it, causing the player to "explode".

Game Demo
We will learn how to create a game where bombs are hidden on a private ZK state, if a player steps into one it will "explode"

This tutorial is part of a three-part series on ZK and autonomous worlds. If you haven't yet, be sure to check out my previous posts.

Table of contents

Create a new MUD project

We'll be using Node 20 (>=18 should be ok), pnpm and foundry. Here are the commands if you haven't installed them yet.

Instalación de dependencias
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20
curl -L https://foundry.paradigm.xyz | bash export PATH=$PATH:~/.foundry/bin
sudo npm install -g pnpm
Enter fullscreen mode Exit fullscreen mode

Once installed create a new MUD project.

pnpm create mud@latest tutorial
cd tutorial
Enter fullscreen mode Exit fullscreen mode

During the installation process select phaser.

slect phaser with mud

1. Define the public sate

Everyone will be able to see each other's positions on-chain and publicly. However bomb positions will be stored on-chain, a commitment and a verifier contract will be stored on chain to prove the validity of the private computation.

packages/contracts/mud.config.ts

import { defineWorld } from "@latticexyz/world";

export default defineWorld({
  namespace: "app",
  enums: {
    Direction: [
      "Up",
      "Down",
      "Left",
      "Right"
    ]
  },
  tables: {
    Player: {
      schema: {
        player: "address",
        x: "int32",
        y: "int32",
        isDead: "bool",
      },
      key: ["player"]
    },
    ZKState: {
      schema: {
        bombsCommitment: "uint32",
        circomVerifier: "address"
      },
      key: [],
    },
  }
});
Enter fullscreen mode Exit fullscreen mode

2. Compute privately on the Circuit

The circuit will handle all the private computation, for this tutorial we'll use Circom.

Let's create the ZK folder structure, following a very similar file structure of a typical Mud project.

mkdir packages/zk
mkdir packages/zk/circuits
mkdir packages/zk/prover
mkdir packages/zk/prover/zk_artifacts
Enter fullscreen mode Exit fullscreen mode

The detonateBomb circuit is capable of producing proofs that a player stepped into one of three bombs secretly placed on the map without revealing the the rest of the bombs. This is done by returning 1 as the result signal if a player steped into one, otherwise 0 is returned. Notice the commitment signal, this is the hash of all the bombs position. This will be later stored on chain to make sure the Game Master can't modify the bomb positions later on the game.

packages/zk/circuits/detonateBomb.circom

pragma circom 2.0.0;

include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/comparators.circom";

template commitmentHasher() {
    signal input bomb1_x;
    signal input bomb1_y;
    signal input bomb2_x;
    signal input bomb2_y;
    signal input bomb3_x;
    signal input bomb3_y;
    signal output commitment;
    component poseidonComponent;
    poseidonComponent = Poseidon(6);
    poseidonComponent.inputs[0] <== bomb1_x;
    poseidonComponent.inputs[1] <== bomb1_y;
    poseidonComponent.inputs[2] <== bomb2_x;
    poseidonComponent.inputs[3] <== bomb2_y;
    poseidonComponent.inputs[4] <== bomb3_x;
    poseidonComponent.inputs[5] <== bomb3_y;
    commitment <== poseidonComponent.out;
}

template detonateBomb() {
    signal input bomb1_x;
    signal input bomb1_y;
    signal input bomb2_x;
    signal input bomb2_y;
    signal input bomb3_x;
    signal input bomb3_y;
    signal input player_x;
    signal input player_y;
    signal output commitment;
    signal output result;

    component commitmentHasherComponent;
    commitmentHasherComponent = commitmentHasher();
    commitmentHasherComponent.bomb1_x <== bomb1_x;
    commitmentHasherComponent.bomb1_y <== bomb1_y;
    commitmentHasherComponent.bomb2_x <== bomb2_x;
    commitmentHasherComponent.bomb2_y <== bomb2_y;
    commitmentHasherComponent.bomb3_x <== bomb3_x;
    commitmentHasherComponent.bomb3_y <== bomb3_y;
    commitment <== commitmentHasherComponent.commitment;

    // Comparators
    signal check_bomb1_x, check_bomb1_y;
    signal check_bomb2_x, check_bomb2_y;
    signal check_bomb3_x, check_bomb3_y;

    check_bomb1_x <== bomb1_x - player_x;
    check_bomb1_y <== bomb1_y - player_y;
    check_bomb2_x <== bomb2_x - player_x;
    check_bomb2_y <== bomb2_y - player_y;
    check_bomb3_x <== bomb3_x - player_x;
    check_bomb3_y <== bomb3_y - player_y;

    // Check if any of the comparisons are zero
    component isz_bomb1_x = IsZero();
    component isz_bomb1_y = IsZero();
    component isz_bomb2_x = IsZero();
    component isz_bomb2_y = IsZero();
    component isz_bomb3_x = IsZero();
    component isz_bomb3_y = IsZero();

    isz_bomb1_x.in <== check_bomb1_x;
    isz_bomb1_y.in <== check_bomb1_y;
    isz_bomb2_x.in <== check_bomb2_x;
    isz_bomb2_y.in <== check_bomb2_y;
    isz_bomb3_x.in <== check_bomb3_x;
    isz_bomb3_y.in <== check_bomb3_y;

    // Aggregate results
    signal match_a, match_b, match_c;
    signal match_any;

    match_a <== isz_bomb1_x.out * isz_bomb1_y.out;
    match_b <== isz_bomb2_x.out * isz_bomb2_y.out;
    match_c <== isz_bomb3_x.out * isz_bomb3_y.out;

    match_any <== match_a + match_b + match_c;

    component isz_final = IsZero();
    isz_final.in <== 1 - match_any;
    isz_final.out ==> result;

    log(result);
    log(commitment);
}

component main {public [player_x, player_y]} = detonateBomb();
Enter fullscreen mode Exit fullscreen mode

This circuit uses the Poseidon hash and the IsZero comparator libraries. You will need to install them as dependencies.

cd packages/zk/circuits
git clone https://github.com/iden3/circomlib.git
Enter fullscreen mode Exit fullscreen mode

Now create an input file to test if everything works fine.

packages/zk/circuits/input.json

{
    "bomb1_x": 1,
    "bomb1_y": 1,
    "bomb2_x": 2,
    "bomb2_y": 2,
    "bomb3_x": 2,
    "bomb3_y": 3,
    "player_x": 2,
    "player_y": 3
}
Enter fullscreen mode Exit fullscreen mode

Compile and generate a proof.

circom detonateBomb.circom --r1cs --wasm --sym
node detonateBomb_js/generate_witness.js detonateBomb_js/detonateBomb.wasm input.json witness.wtns
Enter fullscreen mode Exit fullscreen mode

With the given input the result is equal to 1 because the player stepped into the 2,3 bomb. Also, the commitment is prompted on the terminal, copy paste it somewhere, we'll need it later on the tutorial.

Result:
1
Bombs commitment:
8613278371666841974523698252941148485158405612101680617360618409530277878563
Enter fullscreen mode Exit fullscreen mode

We're using Groth16 for this demo, now run the trusted setup.

snarkjs powersoftau new bn128 12 pot12_0000.ptau -v
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="First contribution" -v
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v
snarkjs groth16 setup detonateBomb.r1cs pot12_final.ptau detonateBomb_0000.zkey
snarkjs zkey contribute detonateBomb_0000.zkey detonateBomb_0001.zkey --name="1st Contributor Name" -v
snarkjs zkey export verificationkey detonateBomb_0001.zkey verification_key.json
snarkjs zkey export solidityverifier detonateBomb_0001.zkey ../../contracts/src/CircomVerifier.sol
Enter fullscreen mode Exit fullscreen mode

Finally, place the artifacts on the prover directory that will be used by the end of this tutorial.

cp detonateBomb_js/detonateBomb.wasm ../prover/zk_artifacts/
cp detonateBomb_0001.zkey ../prover/zk_artifacts/detonateBomb_final.zkey
Enter fullscreen mode Exit fullscreen mode

3. Spawn, move and detonate bombs on Solidity

Go back to the project root.

cd ../../../
Enter fullscreen mode Exit fullscreen mode

Remove non needed files.

rm packages/contracts/src/systems/IncrementSystem.sol packages/contracts/test/CounterTest.t.sol
Enter fullscreen mode Exit fullscreen mode

The game system allows regular players to execute spawn and move and the Game Master to detonateBombs by passing as parameter a ZK proof. Notice we also emit an event when the player moves, this will become handy later on, when we build the ZK prover indexer.

packages/contracts/src/systems/MyGameSystem.sol

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;

import { System } from "@latticexyz/world/src/System.sol";
import { Player, PlayerData, ZKState } from "../codegen/index.sol";
import { Direction } from "../codegen/common.sol";
import { getKeysWithValue } from "@latticexyz/world-modules/src/modules/keyswithvalue/getKeysWithValue.sol";

import { EncodedLengths, EncodedLengthsLib } from "@latticexyz/store/src/EncodedLengths.sol";

interface ICircomVerifier {
    function verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals) external view returns (bool);
}

contract MyGameSystem is System {
  function spawn(int32 x, int32 y) public {
    address playerAddress = _msgSender();
    Player.set(playerAddress, x, y, false);
  }

  function move(Direction direction) public {
    address playerAddress = _msgSender();
    PlayerData memory player = Player.get(playerAddress);

    require(!player.isDead, "Player is dead");

    int32 x = player.x;
    int32 y = player.y;

    if(direction == Direction.Up)
      y-=1;
    if(direction == Direction.Down)
      y+=1;
    if(direction == Direction.Left)
      x-=1;
    if(direction == Direction.Right)
      x+=1;

    require(x>= -31 && x<= 31 && y>= -31 && y<= 31, "Invalid position");

    Player.setX(playerAddress, x);
    Player.setY(playerAddress, y);
  }

  function detonateBomb(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals, address playerAddress) public {
    ICircomVerifier(ZKState.getCircomVerifier()).verifyProof(_pA, _pB, _pC, _pubSignals);
    uint32 commitment = uint32(_pubSignals[0]);
    uint32 result = uint32(_pubSignals[1]);
    int32 guessX = int32(uint32(uint(_pubSignals[2])));
    int32 guessY = int32(uint32(uint(_pubSignals[3])));

    PlayerData memory player = Player.get(playerAddress);

    require(!player.isDead, "Player already dead");
    require(result == 1, "No bomb in this position");
    require(player.x == guessX && player.y == guessY, "Invalid position ");

    uint32 bombsCommitment = ZKState.getBombsCommitment();
    require(uint32(uint(commitment)) == bombsCommitment, "Invalid commitment");

    Player.setIsDead(playerAddress, true);
  }
}
Enter fullscreen mode Exit fullscreen mode

When deploying, the GM will need to store the bomb commitment on-chain so he can't change it later on. Also notice we deploy the Groth16Verifier contract that was generated on step 2.

packages/contracts/script/PostDeploy.s.sol

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;

import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";

import { IWorld } from "../src/codegen/world/IWorld.sol";
import { ZKState } from "../src/codegen/index.sol";
import { Groth16Verifier } from "../src/CircomVerifier.sol";

contract PostDeploy is Script {
  function run(address worldAddress) external {
    StoreSwitch.setStoreAddress(worldAddress);
    uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
    vm.startBroadcast(deployerPrivateKey);

    uint32 bombsCommitment = uint32(uint(8613278371666841974523698252941148485158405612101680617360618409530277878563));
    address circomVerifier = address(new Groth16Verifier());
    ZKState.set(bombsCommitment, circomVerifier);

    vm.stopBroadcast();
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Interact with the users via Phaser

Phaser is the game framework that facilitates animations, key presses, sounds, events. We define those on our Game System.

packages/client/src/layers/phaser/systems/myGameSystem.ts

import { Has, defineEnterSystem, defineSystem, defineExitSystem, getComponentValueStrict } from "@latticexyz/recs";
import { PhaserLayer } from "../createPhaserLayer";
import { 
  pixelCoordToTileCoord,
  tileCoordToPixelCoord
} from "@latticexyz/phaserx";
import { TILE_WIDTH, TILE_HEIGHT, Animations, Directions } from "../constants";

export const createMyGameSystem = (layer: PhaserLayer) => {
  const {
    world,
    networkLayer: {
      components: {
        Player
      },
      systemCalls: {
        spawn,
        move
      }
    },
    scenes: {
        Main: {
            objectPool,
            input
        }
    }
  } = layer;

  let toggle = false;

  input.pointerdown$.subscribe((event) => {
    const x = event.pointer.worldX;
    const y = event.pointer.worldY;
    const player = pixelCoordToTileCoord({ x, y }, TILE_WIDTH, TILE_HEIGHT);
    if(player.x == 0 && player.y == 0)
        return;
    spawn(player.x, player.y) 
  });

  input.onKeyPress((keys) => keys.has("W"), () => {
    move(Directions.UP);
  });

  input.onKeyPress((keys) => keys.has("S"), () => {
    move(Directions.DOWN);
  });

  input.onKeyPress((keys) => keys.has("A"), () => {
    move(Directions.LEFT);
  });

  input.onKeyPress((keys) => keys.has("D"), () => {
    move(Directions.RIGHT);
  });

  input.onKeyPress((keys) => keys.has("I"), () => {
    // This should not be on the client, just for demo purposes
    let bombSprite1 = objectPool.get("Bomb1", "Sprite");
    let bombSprite2 = objectPool.get("Bomb2", "Sprite");
    let bombSprite3 = objectPool.get("Bomb3", "Sprite");
    if (toggle == true) {
      bombSprite1.setComponent({
        id: "position",
        once: (sprite1) => {
          sprite1.setVisible(false);
        }
      })
      bombSprite2.setComponent({
        id: "position",
        once: (sprite2) => {
          sprite2.setVisible(false);
        }
      })
      bombSprite3.setComponent({
        id: "position",
        once: (sprite3) => {
          sprite3.setVisible(false);
        }
      })
    } else {
      bombSprite1.setComponent({
        id: 'animation',
        once: (sprite1) => {
          sprite1.setVisible(true);
          sprite1.play(Animations.Bomb);
          sprite1.setPosition(1*32, 1*32);
        }
      })
      bombSprite2.setComponent({
        id: 'animation',
        once: (sprite2) => {
          sprite2.setVisible(true);
          sprite2.play(Animations.Bomb);
          sprite2.setPosition(2*32, 2*32);
        }
      })
      bombSprite3.setComponent({
        id: 'animation',
        once: (sprite3) => {
          sprite3.setVisible(true);
          sprite3.play(Animations.Bomb);
          sprite3.setPosition(2*32, 3*32);
        }
      })
    }
    toggle = !toggle;
  });

  defineEnterSystem(world, [Has(Player)], ({entity}) => {
    const playerObj = objectPool.get(entity, "Sprite");
    playerObj.setComponent({
        id: 'animation',
        once: (sprite) => {
            sprite.play(Animations.Player);
        }
    })
  });

  defineSystem(world, [Has(Player)], ({ entity }) => {
    const player = getComponentValueStrict(Player, entity);
    const pixelPosition = tileCoordToPixelCoord(player, TILE_WIDTH, TILE_HEIGHT);

    const playerObj = objectPool.get(entity, "Sprite");


    if(player.isDead)
    {
        playerObj.setComponent({
            id: 'animation',
            once: (sprite) => {
              sprite.play(Animations.Dead);
            }
        })
    }

    playerObj.setComponent({
      id: "position",
      once: (sprite) => {
        sprite.setPosition(pixelPosition.x, pixelPosition.y);
      }
    })
  })
};
Enter fullscreen mode Exit fullscreen mode

Now add the images on the sprites directory, separated by folder, with sequential names.

packages/art/sprites/player/1.png
Player 1 Sprite

packages/art/sprites/player/2.png
Player 2 Sprite

packages/art/sprites/bomb/1.png
Bomb sprite

packages/art/sprites/dead/1.png
Dead sprite

And build the tileset.

cd packages/art
yarn
yarn generate-multiatlas-sprites
Enter fullscreen mode Exit fullscreen mode

Finally, define the animation images, names, duration and behavior.

packages/client/src/layers/phaser/configurePhaser.ts

import Phaser from "phaser";
import {
  defineSceneConfig,
  AssetType,
  defineScaleConfig,
  defineMapConfig,
  defineCameraConfig,
} from "@latticexyz/phaserx";
import worldTileset from "../../../public/assets/tilesets/world.png";
import { TileAnimations, Tileset } from "../../artTypes/world";
import { Assets, Maps, Scenes, TILE_HEIGHT, TILE_WIDTH, Animations } from "./constants";

const ANIMATION_INTERVAL = 200;

const mainMap = defineMapConfig({
  chunkSize: TILE_WIDTH * 64, // tile size * tile amount
  tileWidth: TILE_WIDTH,
  tileHeight: TILE_HEIGHT,
  backgroundTile: [Tileset.Grass],
  animationInterval: ANIMATION_INTERVAL,
  tileAnimations: TileAnimations,
  layers: {
    layers: {
      Background: { tilesets: ["Default"] },
      Foreground: { tilesets: ["Default"] },
    },
    defaultLayer: "Background",
  },
});

export const phaserConfig = {
  sceneConfig: {
    [Scenes.Main]: defineSceneConfig({
      assets: {
        [Assets.Tileset]: {
          type: AssetType.Image,
          key: Assets.Tileset,
          path: worldTileset,
        },
        [Assets.MainAtlas]: {
          type: AssetType.MultiAtlas,
          key: Assets.MainAtlas,
          // Add a timestamp to the end of the path to prevent caching
          path: `/assets/atlases/atlas.json?timestamp=${Date.now()}`,
          options: {
            imagePath: "/assets/atlases/",
          },
        },
      },
      maps: {
        [Maps.Main]: mainMap,
      },
      sprites: {
      },
      animations: [
        {
          key: Animations.Player,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 2,
          frameRate: 3,
          repeat: -1,
          prefix: "sprites/player/",
          suffix: ".png",
        },
        {
          key: Animations.Dead,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 12,
          repeat: -1,
          prefix: "sprites/dead/",
          suffix: ".png",
        },
        {
          key: Animations.Bomb,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 12,
          repeat: -1,
          prefix: "sprites/bomb/",
          suffix: ".png",
        },
      ],
      tilesets: {
        Default: {
          assetKey: Assets.Tileset,
          tileWidth: TILE_WIDTH,
          tileHeight: TILE_HEIGHT,
        },
      },
    }),
  },
  scale: defineScaleConfig({
    parent: "phaser-game",
    zoom: 1,
    mode: Phaser.Scale.NONE,
  }),
  cameraConfig: defineCameraConfig({
    pinchSpeed: 1,
    wheelSpeed: 1,
    maxZoom: 3,
    minZoom: 1,
  }),
  cullingChunkSize: TILE_HEIGHT * 16,
};
Enter fullscreen mode Exit fullscreen mode

5. Bind everything together

Setup all the variables and functions that the client needs in order to have visibility of web3 functions defined on the contracts.

packages/client/src/layers/phaser/constants.ts

export enum Scenes {
  Main = "Main",
}

export enum Maps {
  Main = "Main",
}

export enum Animations {
  Player = "Player",
  Dead = "Dead",
  Bomb = "Bomb",
}

export enum Directions {
  UP = 0,  
  DOWN = 1,  
  LEFT = 2,  
  RIGHT = 3,  
}

export enum Assets {
  MainAtlas = "MainAtlas",
  Tileset = "Tileset",
}

export const TILE_HEIGHT = 32;
export const TILE_WIDTH = 32;
Enter fullscreen mode Exit fullscreen mode

packages/client/src/layers/phaser/systems/registerSystems.ts

import { PhaserLayer } from "../createPhaserLayer";
import { createCamera } from "./createCamera";
import { createMapSystem } from "./createMapSystem";
import { createMyGameSystem } from "./myGameSystem";

export const registerSystems = (layer: PhaserLayer) => {
  createCamera(layer);
  createMapSystem(layer);
  createMyGameSystem(layer);
};
Enter fullscreen mode Exit fullscreen mode

packages/client/src/mud/createSystemCalls.ts

import { getComponentValue } from "@latticexyz/recs";
import { ClientComponents } from "./createClientComponents";
import { SetupNetworkResult } from "./setupNetwork";
import { singletonEntity } from "@latticexyz/store-sync/recs";

export type SystemCalls = ReturnType<typeof createSystemCalls>;

export function createSystemCalls(
  { worldContract, waitForTransaction }: SetupNetworkResult,
  { Player }: ClientComponents,
) {
  const spawn = async (x: number, y: number) => {
    const tx = await worldContract.write.app__spawn([x, y]);
    await waitForTransaction(tx);
    return getComponentValue(Player, singletonEntity);
  };

  const move = async (direction: number) => {
    const tx = await worldContract.write.app__move([direction]);
    await waitForTransaction(tx);
    return getComponentValue(Player,  singletonEntity);
  }

  return {
    spawn, move
  };
}
Enter fullscreen mode Exit fullscreen mode

We're ready now to launch the server. Go back to the root folder and deploy it.

cd ../../
pnpm dev
Enter fullscreen mode Exit fullscreen mode

6. Run the prover backend

MUD has its own logging system (known as events in Solidity) where it emits a Store_SetRecord by default every time a record is modified. This is very convenient for us because the tester will listen to all player movement events on-chain, and when it detects that a player has come into contact with a bomb, it will detonate it. This is possible because only the tester has knowledge of the hidden bombs.

Let's start by creating a new npm project and installing the dependencies.

cd packages/zk/prover/
npm init -y
npm install express ethers snarkjs
Enter fullscreen mode Exit fullscreen mode

Now create your server logic node file. We'll use a very simple express server, ethersjs will take care of all the web3 requirements and snarkjs will produce the proofs.

packages/zk/prover/server.js

const express = require('express');
const { ethers } = require('ethers');
const fs = require('fs');
const snarkjs = require('snarkjs');

const contractAddressWorld = "0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b";
const privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const playerTableId = "0x74626170700000000000000000000000506c6179657200000000000000000000";

const bombPositions = JSON.parse(fs.readFileSync('bombs.json', 'utf-8'));

const app = express();
const PORT = 8080;

const provider = new ethers.JsonRpcProvider('http://localhost:8545');
const contractABI = [
    "function detonateBomb(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals, address playerAddress)",
];

const contractABIWorld = [
    "event Store_SetRecord(bytes32 indexed tableId, bytes32[] keyTuple, bytes staticData, bytes32 encodedLengths, bytes dynamicData)",
    "function app__detonateBomb(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals, address playerAddress)"
];

const wallet = new ethers.Wallet(privateKey, provider);
const contractWorld = new ethers.Contract(contractAddressWorld, contractABIWorld, wallet);


function decodeRecord(hexString) {
    if (hexString.startsWith('0x')) {
        hexString = hexString.slice(2);
    }
    const xHex = hexString.slice(0, 8);
    const yHex = hexString.slice(8, 16);
    const isDeadHex = hexString.slice(16, 18);
    const x = parseInt(xHex, 16) | 0;
    const y = parseInt(yHex, 16) | 0;
    const isDead = parseInt(isDeadHex, 16) != 0;
    return { x, y, isDead };
}

contractWorld.on("Store_SetRecord", async (tableId, keyTuple, staticData, encodedLengths, dynamicData) => {
    if(tableId == playerTableId)
    {
        let decodedRecord = decodeRecord(staticData);
        let player = '0x' + keyTuple[0].replace(/^0x000000000000000000000000/, '');

        if(!decodedRecord.isDead)
        {
            await detonateBomb(player, decodedRecord.x, decodedRecord.y);
        }
    }
});

async function detonateBomb(player, x, y) {
    console.log(`Player move to (${x}, ${y})`);
    for (const bomb of bombPositions) {
        if (""+bomb.x === ""+x && ""+bomb.y === ""+y) {
            try {
                const { proof, publicSignals } = await snarkjs.groth16.fullProve(
                    {
                        bomb1_x: bombPositions[0].x,
                        bomb1_y: bombPositions[0].y,
                        bomb2_x: bombPositions[1].x,
                        bomb2_y: bombPositions[1].y,
                        bomb3_x: bombPositions[2].x,
                        bomb3_y: bombPositions[2].y,
                        player_x: x,
                        player_y: y
                    },
                    "./zk_artifacts/detonateBomb.wasm",
                    "./zk_artifacts/detonateBomb_final.zkey"
                );

                let pA = proof.pi_a;
                pA.pop();
                let pB = proof.pi_b;
                pB.pop();
                let pC = proof.pi_c;
                pC.pop();

                if (publicSignals[1] == "1") {
                    const tx = await contractWorld.app__detonateBomb(
                        pA,
                        pB,
                        pC,
                        publicSignals,
                        player
                    );
                    console.log('Transaction:', tx);
                }
            } catch (error) {
                console.error("Error generating or verifying proof:", error);
            }
        }
    }
}

app.get('/', (req, res) => {
    res.send('Server is running');
});

app.listen(PORT, async () => {
    console.log(`Server is listening on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Notice you will need your Player table tableId. Grab it from the ui.

Player Data Id

The private bombs data will be stored in a separate json file.

packages/zk/prover/bombs.json

[
    {"x": 1, "y": 1},
    {"x": 2, "y": 2},
    {"x": 2, "y": 3}
]
Enter fullscreen mode Exit fullscreen mode

To run your prover backend, you will need the MyGameSystem and World address, also a private key of a wallet with ETH.

Grab the World address from your contract process after it finishes loading. Put it on the CONTRACT_ADDRESS_WORLD environment variable.

MUD World Address

Setup the contractAddressWorld and playerTableId variables now.

You are ready to start the prover now.

node server.js
Enter fullscreen mode Exit fullscreen mode

If a player steps into a bomb the prover will detect it and detonate tbe bomb on-chain.

Going forward

If you are familiarized with on chain gaimming you might be wondering: Isn't a minesweeper game possible with a traditional commit-reveal scheme? And yes, that's correct, you can do a minesweeper by commiting to the bombs positions on a, for example, mapping(uint bombId => bytes32 commitment). However, while some commit-reveal complications can be solved by themselves, like for example apply modulus % to the commitment to prevent brute force attacks, ZK introduces new capabilities where I see a lot of potential. Here I share some so you can get inspired by having a sense some of the posibilities.

1. Constrained commitments

When you post a normal commit there is no way of applying constraints to it. On ZK you can, for example define the boundaries of a map, be specific about the amount and caracteristics of the hidden data. For example, there is higher chance to find ore in the mountains or fish in a river. All of this can be defined on the circuit.

2. Data optimizations

ZK allows to reveal portions of a commitment. This means a smaller amount of data is posted on-chain. Yes, verifying zk proofs is expensive at execution level but on L2s execution is cheap and data is expensive, even with danksharding and plasma. That's why cheap execution environments such as Redstone are ideal for this type of games if you are ok with sacrificing a bit of security.

3. More ZK constructions

In this tutorial we went through a PvE scenario, where a player interacts with an environment in adversarial situations. Where all the private off-chain computation is provable and secured on-chain. Now imagine PvP scenarios, or other ZK constructions applied to other types of games.

On future guides I would like to explore other ZK constructions such as private item consuption or private actions by posting merkle inclusion proofs. So stay tuned!

Thanks for reading this guide!

Follow Filosofía Código on dev.to and in Youtube for everything related to Blockchain development.

Top comments (0)