ZK 🤝 Mundos Autónomos: Guía para crear Juegos con Privacidad en Ethereum

Ahmed Castro - Jul 29 - - Dev Community

Zero Knowledge resuelve el problema de cualquier tipo de juego que requiera privacidad para jugarse on-chain. Por ejemplo, permite jugar poker en on-chain, manteniendo privadas las cartas de los jugadores.

En este tutorial, implementaremos el clásico juego de Buscaminas pero on-chain. El creador del juego, que llamaremos Game Master o GM, esconderá bombas en un mapa, y si un jugador se para en una, el GM podrá demostrarlo, haciendo que el jugador "explote".

Demostración de Juego ZK en Mud

Aprenderemos a crear un juego donde las bombas están ocultas en un estado privado ZK. Si un jugador se para en una, "explotará".

Este tutorial es parte de una serie de tres partes sobre ZK y mundos autónomos. Si aún no lo has hecho, asegúrate de revisar mis publicaciones anteriores.

Tabla de contenido

Crea un proyecto de Mud

Usaremos Node v20 (>=v18 debería estar bien), pnpm y foundry. Si no los tienes instaladas, te dejo los comandos.

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

Una vez instalados crea un nuevo proyecto de Mud.

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

Durante la instalación selecciona phaser.

slect phaser with mud

1. Define el estado público

Todos podrán ver las posiciones de los demás de manera pública on-chain. Sin embargo, las posiciones de las bombas se almacenarán off-chain, junto con un commitment y un contrato verificador para probar la validez de la computción privada.

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. Mantén la computación privada en el Circuito

El circuito manejará todos los cálculos privados. Para este tutorial, usaremos Circom.

Vamos a crear la estructura de carpetas de ZK, siguiendo una estructura de archivos muy similar a la de un proyecto típico de Mud.

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

El circuito detonateBomb es capaz de producir pruebas ZK de que un jugador se ha parado en una de las tres bombas colocadas secretamente en el mapa sin revelar las posiciones del resto de las bombas. Esto se logra devolviendo 1 como la señal de result si un jugador está en la misma posición de una bomba, de lo contrario, se devuelve 0. Nótese la señal de commitment, que es el hash de todas las posiciones de las bombas. Este se almacenará posteriormente en la cadena para asegurarse de que el GM no pueda modificar las posiciones de las bombas más adelante en el juego.

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

Este circuito usa las lirberías de Poseidon y IsZero que es parte del set de comparator. Necesitamos instalarlas.

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

Ahora crea un archivo de input para probar si todo funciona bien.

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

Compila y genera una prueba.

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

Con el input anterior, result es igual a 1 porque el jugador caminó en la bomba 2,3. Ademas, en la consola se imprime el commitment. Guárdalo, lo usaremos más adelante.

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

En este demo usaremos Groth16, así que correremos una 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

Finalmente, colocamos los artifactos en la carpeta del prover que correremos al final de este 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. Spawnea, mueve y detona bombas en Solidity

Regresa a la raíz de tu proyecto.

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

Borra los archivos que no necesitaremos.

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

El sistema del juego permite a los jugadores regulares ejecutar spawn y move, y al GM detonar las bombas (detonateBomb) pasando como parámetro una prueba ZK.

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

Al desplegar el juego, el GM necesitará almacenar el commitment de las bombas en la cadena para no poder cambiarlo más adelante. También, notemos que desplegamos el contrato Groth16Verifier que se generó en el paso 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úa con los usuarios a través de Phaser

Phaser es el framework de juegos que facilita las animaciones, las pulsaciones de teclas, los sonidos y los eventos. Definimos estos elementos en nuestro 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

Ahora agrega las imágenes en el directorio sprites, organizadas en carpetas separadas, con nombres secuenciales.

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

Y construye los tilesets automáticamente.

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

Finalmente, define las imágenes de las animaciónes, los nombres, la duración y su comportamiento.

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. Un poco de carpintería

Configura todas las variables y funciones que el cliente necesita para tener visibilidad de las funciones web3 definidas en los contratos.

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

Ahora estamos listos para lanzar el servidor. Regresa a la carpeta raíz y despliega el servidor.

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

6. Corre el prover

MUD tiene su propio sistema de logs (conocido como eventos en Solidity) donde emite por defecto un Store_SetRecord cada vez que se modifica un registro. Esto es muy conveniente para nosotros pues probador escuchará todos los eventos de movimiento de los jugadores on-chain y cuando detecte que un jugador hizo contacto con una bomba la detonará. Esto es posible pues solo él tiene el conocimiento de las bombas ocultas.

Comencemos creando un nuevo proyecto npm e instalando las dependencias.

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

Ahora crea el archivo de lógica del servidor en Node.js. Usaremos un servidor express muy simple, ethers.js se encargará de todos los requisitos web3, y snarkjs producirá las pruebas.

packages/zk/prover/server.js

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

const contractAddress = "0x0B36781F9a9AC42633C35A27504b20dC0F7c0261";
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 contract = new ethers.Contract(contractAddress, contractABI, wallet);
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

Necesitarás la tableId de tu tabla Player. Esta la puedes conseguir en la UI.

Player Data Id

La data privada de las bombas estará almacenada en el un archivo json.

packages/zk/prover/bombs.json

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

Para ejecutar tu prover, necesitarás los addresses de MyGameSystem y World, además de una clave privada de una wallet con ETH.

Localiza el address de MyGameSystem en el frontend. Bajo la pestaña Components, selecciona world_Systems. El sistema que creaste debería ser el último.

Dirección de MyGameSystem en MUD

Obtén el address de World desde tu terminal, la podrás ver cuando los contratos estén lanzados. Colócala en la variable de entorno CONTRACT_ADDRESS_WORLD.

Dirección de World en MUD

Ahora estás listo para iniciar el prover.

CONTRACT_ADDRESS_SYSTEM="0x0B36781F9a9AC42633C35A27504b20dC0F7c0261" CONTRACT_ADDRESS_WORLD="0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b" PRIVATE_KEY="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" node start.js
Enter fullscreen mode Exit fullscreen mode

Si un jugador pisa una bomba, el prover lo detectará y detonará la bomba on-chain.

Llevando tus conocimientos un paso más adelante

Si estás familiarizado con los juegos on-chain, podrías preguntarte: ¿No es posible un juego de Buscaminas con un esquema tradicional de commit-reveal? Y sí, eso es correcto, puedes hacer un Buscaminas haciendo un commit de las posiciones de las bombas en un, por ejemplo, mapping(uint bombId => bytes32 commitment). Sin embargo, aunque algunas complicaciones del esquema commit-reveal pueden resolverse por sí mismas, como aplicar un módulo % al compromiso para prevenir ataques de fuerza bruta, ZK introduce nuevas capacidades donde veo mucho potencial. Aquí comparto algunas para que puedas inspirarte y tener una idea de algunas de las posibilidades.

1. Restricctiones a los Commitments

Cuando publicas un commit normal, no hay forma de aplicarle restricciones. En ZK, puedes, por ejemplo, definir los límites de un mapa, ser específico sobre la cantidad y las características de los datos ocultos. Por ejemplo, hay una mayor probabilidad de encontrar mineral en las montañas o peces en un río. Todo esto puede definirse en el circuito.

2. Optimización de Datos

ZK permite revelar porciones de un commit. Esto significa que se guardan una menor cantidad de datos on-chain. Sí, verificar las pruebas ZK es costoso a nivel de ejecución, pero en L2s la ejecución es barata y los datos son costosos, incluso con danksharding y plasma. Por eso, entornos de ejecución baratos como Redstone son ideales para este tipo de juegos si se está dispuesto a sacrificar un poco de seguridad.

3. Más Construcciones ZK

En este tutorial exploramos un escenario PvE, donde un jugador interactúa con un entorno en situaciones adversariales. Donde todos los cálculos privados off-chain son comprobables y asegurados on-chain. Ahora imagina escenarios PvP u otras construcciones ZK aplicadas a otros tipos de juegos.

En futuras guías, me gustaría explorar otras construcciones ZK, como la consumo privado de ítems o acciones privadas publicando pruebas de inclusión en un árbol Merkle. ¡Así que mantente atento!

¡Gracias por leer esta guía!

Sígueme en dev.to y en Youtube para todo lo relacionado al desarrollo en Blockchain en Español.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player