Cómo crear un Mundo Autónomo con asimetría de información: MUD + Circom

turupawn

Ahmed Castro

Posted on August 20, 2024

Cómo crear un Mundo Autónomo con asimetría de información: MUD + Circom

En este tutorial crearemos un juego 100% on-chain pero con asímetría de información, es decir con estado y computación privada pero 100% verificable en Ethereum. Usaremos MUD, el motor para Mundos Autónomos y Circom, el lenguaje para circuitos ZK más utilizado.

Aprenderás a:

  • Combinar el lenguaje Circom con el framework MUD
  • Crear un mundo autónomo con computación y variables privadas
  • Generar pruebas zk-SNARK desde tu navegador con Snark.js

Tabla de contenido

El juego

Al inicio del juego, cada jugador Spawnea 4 unidades que puede mover a través del mapa. Cada unidad es de un tipo diferente, pero esta infomación es privada, únicamente visibile para el dueño de las unidades.

logic del juego

🐉 que le gana al 🧙 que le gana al 🧌 que le gana al 🗡️. Pero el 🗡️ es una unidad especial pues es la única que puede derrotar al 🐉

Los cuatro tipos definidos en el juego son el 🐉 que le gana al 🧙 que le gana al 🧌 que le gana al 🗡️. Pero el 🗡️ es una unidad especial pues es la única que puede derrotar al 🐉. Los combates se realizan mediante un zk-SNARK que revela el tipo de atacante seguido de otro zk-Snark que revela el resultado de la batalla sin revelar el tipo de quien recibió el ataque.

arquitectura de juego
La arquitectura del juego, nota que toda la data y lógica pública vá en MUD y toda la privacidad en Circom

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

Las posiciones de los personajes se mantienen públicas, definidas en las tablas. Adicionalmente agregamos unos commitments ZK que nos asegurarán que nadie hará trampa, esta parte hará más sentido en la sección de ZK a continación.

packages/contracts/mud.config.ts

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

export default defineWorld({
  namespace: "app",
  enums: {
    Direction: [
      "Up",
      "Down",
      "Left",
      "Right"
    ]
  },
  tables: {
    Character: {
      schema: {
        x: "int32",
        y: "int32",
        owner: "address",
        id: "uint32",
        attackedAt: "uint32",
        attackedByValue: "uint32",
        revealedValue: "uint32",
        isDead: "bool",
      },
      key: ["x", "y"]
    },
    PlayerPrivateState: {
      schema: {
        account: "address",
        commitment: "uint256",
      },
      key: ["account"]
    },
    VerifierContracts: {
      schema: {
        revealContractAddress: "address",
        defendContractAddress: "address"
      },
      key: [],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

2. Crea los circuitos del combate

Los circuitos de batalla están compuestos por el SNARK de ataque y el de defensa.

a. SNARK de ataque

Cuando un personaje ataca, revelará su tipo. Para garantizar que el jugdaor no hizo trampa, crearemos un SNARK que hashea los tipos iniciales que el jugador definió al iniciar el juego. El circuito se encarga de asegurar que el jugador ha asignado un personaje de cada tipo, y luego todo se hashea junto con un privateSalt, que actúa como una llave privada y previene ataques de fuerza bruta para descubrir el estado inicial al que el jugador hizo commit. A este hash le llamamos commitment, que se almacena en una tabla de MUD que ayudará a verificar que todo ocurró correctamente, más detalles sobre esto en la sección de los contratos a continuación.

packages/zk/circuits/reveal/reveal.circom

pragma circom 2.0.0;

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

template spawn() {
    // Input signals
    signal input character1;
    signal input character2;
    signal input character3;
    signal input character4;
    signal input privateSalt;
    signal input characterReveal; // The character index to reveal (1, 2, 3, or 4)
    signal input valueReveal; // The value that is claimed to be assigned to the character

    // Output signal for the hash
    signal output hash;

    // Poseidon hash calculation
    component poseidonComponent = Poseidon(5);
    poseidonComponent.inputs[0] <== character1;
    poseidonComponent.inputs[1] <== character2;
    poseidonComponent.inputs[2] <== character3;
    poseidonComponent.inputs[3] <== character4;
    poseidonComponent.inputs[4] <== privateSalt;
    hash <== poseidonComponent.out;

    // Comparator components for character reveal verification
    component isChar1 = IsEqual();
    component isChar2 = IsEqual();
    component isChar3 = IsEqual();
    component isChar4 = IsEqual();
    isChar1.in[0] <== characterReveal;
    isChar1.in[1] <== 1;
    isChar2.in[0] <== characterReveal;
    isChar2.in[1] <== 2;
    isChar3.in[0] <== characterReveal;
    isChar3.in[1] <== 3;
    isChar4.in[0] <== characterReveal;
    isChar4.in[1] <== 4;

    // Value check depending on the revealed character
    component checkChar1 = IsEqual();
    component checkChar2 = IsEqual();
    component checkChar3 = IsEqual();
    component checkChar4 = IsEqual();

    checkChar1.in[0] <== isChar1.out * character1 + (1 - isChar1.out) * 0;
    checkChar1.in[1] <== valueReveal;

    checkChar2.in[0] <== isChar2.out * character2 + (1 - isChar2.out) * 0;
    checkChar2.in[1] <== valueReveal;

    checkChar3.in[0] <== isChar3.out * character3 + (1 - isChar3.out) * 0;
    checkChar3.in[1] <== valueReveal;

    checkChar4.in[0] <== isChar4.out * character4 + (1 - isChar4.out) * 0;
    checkChar4.in[1] <== valueReveal;

    signal validReveal1;
    signal validReveal2;
    signal validReveal3;
    signal validReveal4;

    validReveal1 <== checkChar1.out;
    validReveal2 <== checkChar2.out;
    validReveal3 <== checkChar3.out;
    validReveal4 <== checkChar4.out;

    signal validReveal <== validReveal1 + validReveal2 + validReveal3 + validReveal4;
    validReveal === 1;

    // Comparators to check for presence of values 1, 2, 3, 4
    component isOne1 = IsEqual();
    component isOne2 = IsEqual();
    component isOne3 = IsEqual();
    component isOne4 = IsEqual();
    isOne1.in[0] <== character1;
    isOne1.in[1] <== 1;
    isOne2.in[0] <== character2;
    isOne2.in[1] <== 1;
    isOne3.in[0] <== character3;
    isOne3.in[1] <== 1;
    isOne4.in[0] <== character4;
    isOne4.in[1] <== 1;
    signal oneExists <== isOne1.out + isOne2.out + isOne3.out + isOne4.out;
    oneExists === 1;

    component isTwo1 = IsEqual();
    component isTwo2 = IsEqual();
    component isTwo3 = IsEqual();
    component isTwo4 = IsEqual();
    isTwo1.in[0] <== character1;
    isTwo1.in[1] <== 2;
    isTwo2.in[0] <== character2;
    isTwo2.in[1] <== 2;
    isTwo3.in[0] <== character3;
    isTwo3.in[1] <== 2;
    isTwo4.in[0] <== character4;
    isTwo4.in[1] <== 2;
    signal twoExists <== isTwo1.out + isTwo2.out + isTwo3.out + isTwo4.out;
    twoExists === 1;

    component isThree1 = IsEqual();
    component isThree2 = IsEqual();
    component isThree3 = IsEqual();
    component isThree4 = IsEqual();
    isThree1.in[0] <== character1;
    isThree1.in[1] <== 3;
    isThree2.in[0] <== character2;
    isThree2.in[1] <== 3;
    isThree3.in[0] <== character3;
    isThree3.in[1] <== 3;
    isThree4.in[0] <== character4;
    isThree4.in[1] <== 3;
    signal threeExists <== isThree1.out + isThree2.out + isThree3.out + isThree4.out;
    threeExists === 1;

    component isFour1 = IsEqual();
    component isFour2 = IsEqual();
    component isFour3 = IsEqual();
    component isFour4 = IsEqual();
    isFour1.in[0] <== character1;
    isFour1.in[1] <== 4;
    isFour2.in[0] <== character2;
    isFour2.in[1] <== 4;
    isFour3.in[0] <== character3;
    isFour3.in[1] <== 4;
    isFour4.in[0] <== character4;
    isFour4.in[1] <== 4;
    signal fourExists <== isFour1.out + isFour2.out + isFour3.out + isFour4.out;
    fourExists === 1;
}

component main {public [characterReveal, valueReveal]} = spawn();
Enter fullscreen mode Exit fullscreen mode

b. SNARK de defensa

Cuando un personaje es atacado, entra en un estado en el que no puede moverse ni atacar. Para salir de este estado, debe presentar un SNARK que demuestre que su defensa fue exitosa sin necesidad de revelar su tipo. Esto se logra mediante un circuito que verifica la defensa en baseal tipo público del atacante y el tipo privado de la defensa.

packages/zk/circuits/defend/defend.circom

pragma circom 2.0.0;

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

template CharacterBattleCheck() {
    // Input signals
    signal input character1;
    signal input character2;
    signal input character3;
    signal input character4;
    signal input privateSalt;
    signal input characterTarget; // 1-based index: 1 for character1, 2 for character2, etc.
    signal input attackerLevel;

    // Output signal for the hash
    signal output hash;

    // Output signal for the battle result
    signal output battleResult;

    // Poseidon hash calculation
    component poseidonComponent = Poseidon(5);
    poseidonComponent.inputs[0] <== character1;
    poseidonComponent.inputs[1] <== character2;
    poseidonComponent.inputs[2] <== character3;
    poseidonComponent.inputs[3] <== character4;
    poseidonComponent.inputs[4] <== privateSalt;
    hash <== poseidonComponent.out;

    // Create binary indicators for each target
    signal isTarget1;
    signal isTarget2;
    signal isTarget3;
    signal isTarget4;

    // Check if characterTarget matches 1, 2, 3, or 4
    component isTarget1Eq = IsEqual();
    isTarget1Eq.in[0] <== characterTarget;
    isTarget1Eq.in[1] <== 1;
    isTarget1 <== isTarget1Eq.out;

    component isTarget2Eq = IsEqual();
    isTarget2Eq.in[0] <== characterTarget;
    isTarget2Eq.in[1] <== 2;
    isTarget2 <== isTarget2Eq.out;

    component isTarget3Eq = IsEqual();
    isTarget3Eq.in[0] <== characterTarget;
    isTarget3Eq.in[1] <== 3;
    isTarget3 <== isTarget3Eq.out;

    component isTarget4Eq = IsEqual();
    isTarget4Eq.in[0] <== characterTarget;
    isTarget4Eq.in[1] <== 4;
    isTarget4 <== isTarget4Eq.out;

    // Ensure exactly one of the targets is selected
    signal sumTargets;
    sumTargets <== isTarget1 + isTarget2 + isTarget3 + isTarget4;
    sumTargets === 1;

    // Use separate variables to hold the selected character values
    signal selectedCharacter1;
    signal selectedCharacter2;
    signal selectedCharacter3;
    signal selectedCharacter4;

    // Enforce that only one of the selectedCharacter variables holds the value
    selectedCharacter1 <== isTarget1 * character1;
    selectedCharacter2 <== isTarget2 * character2;
    selectedCharacter3 <== isTarget3 * character3;
    selectedCharacter4 <== isTarget4 * character4;

    // Aggregate the selected character value
    signal selectedCharacter;
    selectedCharacter <== selectedCharacter1 + selectedCharacter2 + selectedCharacter3 + selectedCharacter4;

    // Compare attackerLevel and selectedCharacter
    component compareLevel = LessThan(4); // Assuming levels are within 4 bits (0-15)
    compareLevel.in[0] <== selectedCharacter;
    compareLevel.in[1] <== attackerLevel;
    signal attackerWinsNormal <== compareLevel.out;

    // Special rule: attackerLevel == 1 and selectedCharacter == 4
    component isAttackerLevelOneEq = IsEqual();
    isAttackerLevelOneEq.in[0] <== attackerLevel;
    isAttackerLevelOneEq.in[1] <== 1;
    signal isAttackerLevelOne <== isAttackerLevelOneEq.out;

    component isCharacterTargetFourEq = IsEqual();
    isCharacterTargetFourEq.in[0] <== selectedCharacter;
    isCharacterTargetFourEq.in[1] <== 4;
    signal isCharacterTargetFour <== isCharacterTargetFourEq.out;

    signal attackerWinsSpecial;
    attackerWinsSpecial <== isAttackerLevelOne * isCharacterTargetFour;

    // Determine if the attacker wins either normally or via special rule
    signal attackerWins;
    attackerWins <== attackerWinsNormal + attackerWinsSpecial;

    // Convert attackerWins to a binary value (0 or 1)
    signal isAttackerWins;
    signal zeroFlag;
    signal oneFlag;

    // Determine zeroFlag: 1 if attackerWins == 0, else 0
    zeroFlag <== attackerWins * (attackerWins - 1);
    oneFlag <== 1 - zeroFlag;

    // isAttackerWins should be 1 if attackerWins > 0, else 0
    isAttackerWins <== attackerWins - zeroFlag;

    // Calculate the battleResult: 1 if defender wins, 2 if attacker wins
    signal defenderWins;
    defenderWins <== 1 - isAttackerWins;

    // Output battleResult: 1 if defender wins, 2 if attacker wins
    battleResult <== 1 + isAttackerWins;

    log(battleResult);
}

component main {public [characterTarget, attackerLevel]} = CharacterBattleCheck();
Enter fullscreen mode Exit fullscreen mode

Como podrás notar, estamos usando las librerías de Poseidon de y los comparadores, instálalas.

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

c. Crea los contratos verificadores

Ingresa en la carpeta del circuito de reveal.

cd reveal
Enter fullscreen mode Exit fullscreen mode

Compila el circuito.

circom reveal.circom --r1cs --wasm --sym
Enter fullscreen mode Exit fullscreen mode

Genera la ceremonia inicial y el contrato verificador.

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 reveal.r1cs pot12_final.ptau reveal_0000.zkey
snarkjs zkey contribute reveal_0000.zkey reveal_0001.zkey --name="1st Contributor Name" -v
snarkjs zkey export verificationkey reveal_0001.zkey verification_key.json
snarkjs zkey export solidityverifier reveal_0001.zkey ../../../contracts/src/RevealVerifier.sol
Enter fullscreen mode Exit fullscreen mode

Colócalo en la carpeta de contratos de MUD.

mkdir ../../../client/public/zk_artifacts/
cp reveal_js/reveal.wasm ../../../client/public/zk_artifacts/
cp reveal_0001.zkey ../../../client/public/zk_artifacts/reveal_final.zkey
Enter fullscreen mode Exit fullscreen mode

Ahora haz lo mismo con el circuito de defensa.

cd ../defend
Enter fullscreen mode Exit fullscreen mode

Compilamos.

circom defend.circom --r1cs --wasm --sym
Enter fullscreen mode Exit fullscreen mode

Generamos el contrato verificador.

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 defend.r1cs pot12_final.ptau defend_0000.zkey
snarkjs zkey contribute defend_0000.zkey defend_0001.zkey --name="1st Contributor Name" -v
snarkjs zkey export verificationkey defend_0001.zkey verification_key.json
snarkjs zkey export solidityverifier defend_0001.zkey ../../../contracts/src/DefendVerifier.sol
Enter fullscreen mode Exit fullscreen mode

Lo colocamos en la carpeta de contratos.

mkdir ../../../client/public/zk_artifacts/
cp defend_js/defend.wasm ../../../client/public/zk_artifacts/
cp defend_0001.zkey ../../../client/public/zk_artifacts/defend_final.zkey
Enter fullscreen mode Exit fullscreen mode

También deberás cambiar el nombre génerico de Groth16Verifier por RevealVerifier y DefendVerifier respectivamente en los contratos que recién colocamos en packages/client/public/zk_artifacts/.

3. La lógica del juego

Borramos un par de archivos que no usaremos.

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

Toda la lógica del juego se define en Solidity. Al inicio, el spawn y luego el ataque y la defensa con sos pruebas ZK correspondientes. También agregamos una función killUnresponsiveCharacter pues si un jugador no quiere presentar la prueba ZK de su defensa en tiempo será eliminado.

packages/contracts/src/systems/MyGameSystem.sol

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

import { System } from "@latticexyz/world/src/System.sol";
import { Character, CharacterData, VerifierContracts } from "../codegen/index.sol";
import { PlayerPrivateState } 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 ICircomRevealVerifier {
    function verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[3] calldata _pubSignals) external view returns (bool);
}

interface ICircomDefendVerifier {
    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, uint256 commitment) public {
    //require(PlayerPrivateState.getCommitment(_msgSender()) == 0, "Player already spawned");

    Character.set(x, y, _msgSender(), 1, 0, 0, 0, false);
    Character.set(x, y + 1, _msgSender(), 2, 0, 0, 0, false);
    Character.set(x, y + 2, _msgSender(), 3, 0, 0, 0, false);
    Character.set(x, y + 3, _msgSender(), 4, 0, 0, 0, false);

    PlayerPrivateState.set(_msgSender(), commitment);
  }

  function move(int32 characterAtX, int32 characterAtY, Direction direction) public {
    CharacterData memory character = Character.get(characterAtX, characterAtY);

    //require(!character.isDead, "Character is dead");
    require(character.attackedAt == 0, "Character is under attack");
    require(character.owner == _msgSender(), "Only owner");

    int32 x = characterAtX;
    int32 y = characterAtY;

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

    CharacterData memory characterAtDestination = Character.get(x, y);
    require(characterAtDestination.owner == address(0), "Destination is occupied");

    Character.deleteRecord(characterAtX, characterAtY);
    Character.set(x, y, _msgSender(), character.id, 0, 0, character.revealedValue, false);
  }

  function attack(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[3] calldata _pubSignals,
    int32 fromX, int32 fromY, int32 toX, int32 toY
  ) public {
    ICircomRevealVerifier(VerifierContracts.getRevealContractAddress()).verifyProof(_pA, _pB, _pC, _pubSignals);
    uint256 commitment = _pubSignals[0];
    uint256 characterReveal = _pubSignals[1];
    uint256 valueReveal = _pubSignals[2];

    require(PlayerPrivateState.getCommitment(_msgSender()) == commitment, "Invalid commitment");
    require(characterReveal == Character.getId(fromX, fromY), "Invalid attacker id");
    require(Character.getOwner(fromX, fromY) == _msgSender(), "You're not the planet owner");
    Character.setRevealedValue(fromX, fromY, uint32(valueReveal));
    Character.setAttackedAt(toX, toY, uint32(block.timestamp));
    Character.setAttackedByValue(toX, toY, uint32(valueReveal));
  }

  function defend(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals,
    int32 x, int32 y
  ) public {
    ICircomDefendVerifier(VerifierContracts.getDefendContractAddress()).verifyProof(_pA, _pB, _pC, _pubSignals);

    uint256 commitment = _pubSignals[0];
    uint256 battleResult = _pubSignals[1];
    uint256 characterTarget = _pubSignals[2];
    uint256 attackerLevel = _pubSignals[3];

    require(PlayerPrivateState.getCommitment(Character.getOwner(x, y)) == commitment, "Invalid commitment");
    require(characterTarget == Character.getId(x, y), "Invalid character id");
    require(attackerLevel == Character.getAttackedByValue(x, y), "Invalid attacked by value in proof");

    if(battleResult == 1) { // defense won
      Character.setAttackedAt(x, y, 0);
      Character.setAttackedByValue(x, y, 0);
    } else { // attack won
      Character.setIsDead(x, y, true);
    }
  }

  function killUnresponsiveCharacter(int32 x, int32 y) public {
    uint32 attackedAt = Character.getAttackedAt(x, y);
    uint32 MAX_WAIT_TIME = 1 minutes;
    require(attackedAt>0 && (attackedAt - uint32(block.timestamp)) >  MAX_WAIT_TIME, "Can kill character now");
    Character.setIsDead(x, y, true);
  }
}
Enter fullscreen mode Exit fullscreen mode

Recuerda que en MUD no hacemos uso de los constructores tradicionales, esto es dado que un mismo contrato de tipo System puede manejar el estado de varios mundos. Es por eso que usamos el contrato de PostDeploy como alternativa, donde lanzamos los contratos verificadores.

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 { RevealVerifier } from "../src/RevealVerifier.sol";
import { DefendVerifier } from "../src/DefendVerifier.sol";

import { IWorld } from "../src/codegen/world/IWorld.sol";

import { VerifierContracts } from "../src/codegen/index.sol";

contract PostDeploy is Script {
  function run(address worldAddress) external {
    // Specify a store so that you can use tables directly in PostDeploy
    StoreSwitch.setStoreAddress(worldAddress);

    // Load the private key from the `PRIVATE_KEY` environment variable (in .env)
    uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");

    // Start broadcasting transactions from the deployer account
    vm.startBroadcast(deployerPrivateKey);

    address revealVerifier = address(new RevealVerifier());
    VerifierContracts.setRevealContractAddress(revealVerifier);

    address defendVerifier = address(new DefendVerifier());
    VerifierContracts.setDefendContractAddress(defendVerifier);

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

4. El cliente con Phaser + Snarkjs

Creamos el archivo que manejará la lógica de la interfaz de usuario. Es decir que que maneja las acciones del mouse de drag para mover, conectar dos personajes para atacar y un click para defender. Adicionalmente este archivo define la lógica de las animaciones.

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

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

function decodePosition(hexString) {
    if (hexString.startsWith('0x')) {
        hexString = hexString.slice(2);
    }

    const halfLength = hexString.length / 2;
    const firstHalfHex = hexString.slice(0, halfLength);
    const secondHalfHex = hexString.slice(halfLength);

    const firstHalfInt32 = getSignedInt32(firstHalfHex);
    const secondHalfInt32 = getSignedInt32(secondHalfHex);

    return { x: firstHalfInt32, y: secondHalfInt32 };
}

function getSignedInt32(hexStr) {
    const int32Value = parseInt(hexStr.slice(-8), 16);

    if (int32Value > 0x7FFFFFFF) {
        return int32Value - 0x100000000;
    }
    return int32Value;
}

function encodePosition(x: number, y: number): string {
    const xHex = int256ToHex(x);
    const yHex = int256ToHex(y);

    // Concatenate the two 32-byte hex values to form a 64-byte hex string
    return '0x' + xHex + yHex;
}

function int256ToHex(value: number): string {
    // If the value is negative, convert it to a 256-bit unsigned integer
    if (value < 0) {
        value = BigInt('0x10000000000000000000000000000000000000000000000000000000000000000') + BigInt(value);
    } else {
        value = BigInt(value);
    }

    // Convert the integer to a hexadecimal string, ensuring it has 64 characters (256 bits)
    let hexStr = value.toString(16);
    while (hexStr.length < 64) {
        hexStr = '0' + hexStr;
    }

    return hexStr;
}

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

  let startPoint: { x: number; y: number } | null = null;
  let draggedEntity: string | null = null;

  let playerRectangle1 = objectPool.get("PlayerRectangle1", "Rectangle");
  let playerRectangle2 = objectPool.get("PlayerRectangle2", "Rectangle");
  let playerRectangle3 = objectPool.get("PlayerRectangle3", "Rectangle");
  let playerRectangle4 = objectPool.get("PlayerRectangle4", "Rectangle");
  let arrowLine1 = objectPool.get("ArrowLine1", "Line");
  let arrowLine2 = objectPool.get("ArrowLine2", "Line");
  let arrowLine3 = objectPool.get("ArrowLine3", "Line");

  let secretCharacterValues = [0, 4, 1, 2, 3];
  let privateSalt = 123;

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

    if (player.x === 0 && player.y === 0) return;

    let coordinates = pixelCoordToTileCoord({ x: worldX, y: worldY }, TILE_WIDTH, TILE_HEIGHT);
    let encodedPosition = encodePosition(coordinates.x, coordinates.y);

    const character = getComponentValue(Character, encodedPosition);

    if (character) {
        startPoint = { x: worldX, y: worldY };
        draggedEntity = `${player.x}-${player.y}`;
    } else {
        spawn(player.x, player.y, 123);
    }
  });

  input.pointermove$.subscribe((event) => {
    if (startPoint && draggedEntity) {
        const { worldX, worldY } = event.pointer;

        // Draw the main line
        arrowLine1.setComponent({
          id: "line",
          once: (line) => {
            line.visible = true;
            line.isStroked = true;
            line.setFillStyle(0xff00ff);
            line.geom.x1 = startPoint.x;
            line.geom.y1 = startPoint.y;
            line.geom.x2 = worldX;
            line.geom.y2 = worldY;
          },
        });

        // Draw an arrowhead effect at the end point
        const arrowLength = 20;
        const angle = Math.atan2(worldY - startPoint.y, worldX - startPoint.x);

        arrowLine2.setComponent({
          id: "line",
          once: (line) => {
            line.visible = true;
            line.isStroked = true;
            line.setFillStyle(0x00ff00);
            line.geom.x1 = worldX;
            line.geom.y1 = worldY;
            line.geom.x2 = worldX - arrowLength * Math.cos(angle - Math.PI / 6);
            line.geom.y2 = worldY - arrowLength * Math.sin(angle - Math.PI / 6);
          },
        });

        arrowLine3.setComponent({
          id: "line",
          once: (line) => {
            line.visible = true;
            line.isStroked = true;
            line.setFillStyle(0x00ff00);
            line.geom.x1 = worldX;
            line.geom.y1 = worldY;
            line.geom.x2 = worldX - arrowLength * Math.cos(angle + Math.PI / 6);
            line.geom.y2 = worldY - arrowLength * Math.sin(angle + Math.PI / 6);
          },
        });
    }
  });

  input.pointerup$.subscribe((event) => {
    if (startPoint && draggedEntity) {

      const { worldX, worldY } = event.pointer;

      const startTile = pixelCoordToTileCoord(startPoint, TILE_WIDTH, TILE_HEIGHT);
      const endTile = pixelCoordToTileCoord({ x: worldX, y: worldY }, TILE_WIDTH, TILE_HEIGHT);

      const encodedDestinationPosition = encodePosition(endTile.x, endTile.y);
      const destinationCharacter = getComponentValue(Character, encodedDestinationPosition);
      const direction = calculateDirection(startTile, endTile);

      if(startTile.x == endTile.x
          && startTile.y == endTile.y)
      {

        console.log(`Defending character at (${startTile.x}, ${startTile.y})`);

        defend(startTile.x, startTile.y,
        {
          character1: secretCharacterValues[1],
          character2: secretCharacterValues[2],
          character3: secretCharacterValues[3],
          character4: secretCharacterValues[4],
          privateSalt: privateSalt,
          characterTarget: destinationCharacter.id,
          attackerLevel: destinationCharacter.attackedByValue
        });

      } else if (destinationCharacter) {
        const encodedStartPosition = encodePosition(startTile.x, startTile.y);
        const startCharacter = getComponentValue(Character, encodedStartPosition);

        attack(startTile.x, startTile.y, endTile.x, endTile.y,
          {
            character1: secretCharacterValues[1],
            character2: secretCharacterValues[2],
            character3: secretCharacterValues[3],
            character4: secretCharacterValues[4],
            privateSalt: privateSalt,
            characterReveal: startCharacter.id,
            valueReveal: secretCharacterValues[startCharacter.id]
          }
        );
        console.log(`Attacked character from (${startTile.x}, ${startTile.y}) to (${endTile.x}, ${endTile.y})`);
      } else if (direction != null) {
        move(startTile.x, startTile.y, direction);
        console.log(`Moved character from (${startTile.x}, ${startTile.y}) to (${endTile.x}, ${endTile.y}) in direction ${direction}`);
      } 

      startPoint = null;
      draggedEntity = null;
    }
  });

  defineEnterSystem(world, [Has(Character)], ({ entity }) => {
    const character = getComponentValue(Character, entity);

    const characterObj = objectPool.get(entity, "Sprite");
    characterObj.setComponent({
      id: 'animation',
      once: (sprite) => {
        let characterAnimation = character.revealedValue;
        const playerIsOwner = "0x" + playerEntity.slice(26).toLowerCase() == "" + character.owner.toLowerCase()
        if(playerIsOwner) {
          characterAnimation = secretCharacterValues[character.id];
        }
        if(playerIsOwner) {
          const characterPosition = tileCoordToPixelCoord(decodePosition(entity), TILE_WIDTH, TILE_HEIGHT);
          let rectangle = null

          switch (character.id)  {
            case 1:
              rectangle = playerRectangle1;
              break;
            case 2:
              rectangle = playerRectangle2;
              break;
            case 3:
              rectangle = playerRectangle3;
              break;
            case 4:
              rectangle = playerRectangle4;
              break;
          }

          rectangle.setComponent({
            id: "rectangle",
            once: (rectangle) => {
              rectangle.setPosition(characterPosition.x, characterPosition.y);
              rectangle.setSize(32,32);
              rectangle.setFillStyle(0x0000ff);
              rectangle.setAlpha(0.25);
            },
          });

        }
        switch (characterAnimation)  {
          case 1:
            sprite.play(Animations.A);
            break;
          case 2:
            sprite.play(Animations.B);
            break;
          case 3:
            sprite.play(Animations.C);
            break;
          case 4:
            sprite.play(Animations.D);
            break;
          default:
            sprite.play(Animations.Unknown);
        }
      }
    });
  });

  defineExitSystem(world, [Has(Character)], ({ entity }) => {
    objectPool.remove(entity);
  });

  defineSystem(world, [Has(Character)], ({ entity }) => {
    const character = getComponentValue(Character, entity);
    if(!character)
      return;
    const pixelPosition = tileCoordToPixelCoord(decodePosition(entity), TILE_WIDTH, TILE_HEIGHT);
    const characterObj = objectPool.get(entity, "Sprite");

    if (character.isDead) {
      characterObj.setComponent({
        id: 'animation',
        once: (sprite) => {
          sprite.play(Animations.Dead);
        }
      });
    } else if (character.attackedAt != 0) {
      characterObj.setComponent({
        id: 'animation',
        once: (sprite) => {
          sprite.play(Animations.Attacked);
        }
      });
    } else
    {
      characterObj.setComponent({
        id: 'animation',
        once: (sprite) => {
          let characterAnimation = character.revealedValue;
          const playerIsOwner = "0x" + playerEntity.slice(26).toLowerCase() == "" + character.owner.toLowerCase()
          if(playerIsOwner) {
            characterAnimation = secretCharacterValues[character.id];
          }
          if(playerIsOwner) {
            //sprite.setBackgroundColor("#0000ff");
          }
          switch (characterAnimation)  {
            case 1:
              sprite.play(Animations.A);
              break;
            case 2:
              sprite.play(Animations.B);
              break;
            case 3:
              sprite.play(Animations.C);
              break;
            case 4:
              sprite.play(Animations.D);
              break;
            default:
              sprite.play(Animations.Unknown);
          }
        }
      });
    }

    characterObj.setComponent({
      id: "position",
      once: (sprite) => {
        sprite.setPosition(pixelPosition.x, pixelPosition.y);
      }
    });

    arrowLine1.setComponent({
      id: "line",
      once: (line) => {
        line.visible = false;
      },
    });

    arrowLine2.setComponent({
      id: "line",
      once: (line) => {
        line.visible = false;
      },
    });

    arrowLine3.setComponent({
      id: "line",
      once: (line) => {
        line.visible = false;
      },
    });
  });

  function calculateDirection(start: { x: number, y: number }, end: { x: number, y: number }) {
    if (end.y < start.y) return Directions.UP;
    if (end.y > start.y) return Directions.DOWN;
    if (end.x < start.x) return Directions.LEFT;
    if (end.x > start.x) return Directions.RIGHT;
    return null;
  }
};
Enter fullscreen mode Exit fullscreen mode

5. Interacción Cliente-Ethereum y Cliente-SNARK

Antes, debemos instalar la librería que nos ayudará a producir SNARKs

cd packages/client/
pnpm install snarkjs
Enter fullscreen mode Exit fullscreen mode

Ahora podemos definir las transacciones on-chain y la genereción de pruebas ZK.

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";
import { groth16 } from "snarkjs";

export type SystemCalls = ReturnType<typeof createSystemCalls>;

export function createSystemCalls(
  { worldContract, waitForTransaction, playerEntity }: SetupNetworkResult,
  { Character }: ClientComponents,
) {
  const spawn = async (x: number, y: number) => {
    const { proof, publicSignals } = await groth16.fullProve(
      {
          character1: 4,
          character2: 1,
          character3: 2,
          character4: 3,
          privateSalt: 123,
          characterReveal: 1,
          valueReveal: 4,
      },
      "./zk_artifacts/reveal.wasm",
      "./zk_artifacts/reveal_final.zkey"
    );
    let commitment : number = publicSignals[0];
    const tx = await worldContract.write.app__spawn([x, y, commitment]);
    await waitForTransaction(tx);
    return getComponentValue(Character, singletonEntity);
  };

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

  const attack = async (fromX: number, fromY: number, toX: number, toY: number, circuitInputs: any) => {
    const { proof, publicSignals } = await groth16.fullProve(circuitInputs,
      "./zk_artifacts/reveal.wasm",
      "./zk_artifacts/reveal_final.zkey"
    );

    let pa = proof.pi_a
    let pb = proof.pi_b
    let pc = proof.pi_c
    pa.pop()
    pb.pop()
    pc.pop()

    const tx = await worldContract.write.app__attack([pa, pb, pc, publicSignals, fromX, fromY, toX, toY]);
    await waitForTransaction(tx);
    return getComponentValue(Character,  singletonEntity);
  }

  const defend = async (x: number, y: number, circuitInputs: any) => {
    const { proof, publicSignals } = await groth16.fullProve(circuitInputs,
      "./zk_artifacts/defend.wasm",
      "./zk_artifacts/defend_final.zkey"
    );

    let pa = proof.pi_a
    let pb = proof.pi_b
    let pc = proof.pi_c
    pa.pop()
    pb.pop()
    pc.pop()

    const tx = await worldContract.write.app__defend([pa, pb, pc, publicSignals, x, y]);
    await waitForTransaction(tx);
    return getComponentValue(Character,  singletonEntity);
  }

  return {
    spawn, move, attack, defend, playerEntity
  };
}
Enter fullscreen mode Exit fullscreen mode

6. Colocamos las animaciones del juego

Puedes usar las animaciones que desees pero si quieres usar las mismas que estoy usando puedes descargar estos artes:

packages/art/sprites/A/1.png
caballero

packages/art/sprites/B/1.png
ogro

packages/art/sprites/C/1.png
mago

packages/art/sprites/D/1.png
dragon

packages/art/sprites/Attacked/1.png
attacked

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

packages/art/sprites/Unknown/1.png
unknown

Generamos los el atlas.

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

Y definimos las animaciones en el juego. Aquí puedes agregar animaciones de varios cuadros y establecer el comportamiento y velocidad de estas.

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.A,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 3,
          repeat: -1,
          duration: 1,
          prefix: "sprites/A/",
          suffix: ".png",
        },
        {
          key: Animations.B,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 3,
          repeat: -1,
          duration: 1,
          prefix: "sprites/B/",
          suffix: ".png",
        },
        {
          key: Animations.C,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 3,
          repeat: -1,
          duration: 1,
          prefix: "sprites/C/",
          suffix: ".png",
        },
        {
          key: Animations.D,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 3,
          repeat: -1,
          duration: 1,
          prefix: "sprites/D/",
          suffix: ".png",
        },
        {
          key: Animations.Dead,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 12,
          repeat: -1,
          duration: 1,
          prefix: "sprites/Dead/",
          suffix: ".png",
        },
        {
          key: Animations.Unknown,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 12,
          repeat: -1,
          duration: 1,
          prefix: "sprites/Unknown/",
          suffix: ".png",
        },
        {
          key: Animations.Attacked,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 12,
          repeat: -1,
          duration: 1,
          prefix: "sprites/Attacked/",
          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

7. Finalmente un poco de carpintería

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

export enum Scenes {
  Main = "Main",
}

export enum Maps {
  Main = "Main",
}

export enum Animations {
  A = "A",
  B = "B",
  C = "C",
  D = "D",
  Dead = "Dead",
  Unknown = "Unknown",
  Attacked = "Attacked",
}

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

8. Corre el juego

Regresamos a la carpeta raíz de nuestro proyecto.

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

Y corremos el juego.

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Ahora puedes abrir el juego en dos navegadores diferentes. Cada jugador puede spawnear haciendo clic en un lugar vacío. Drag hacia un espacio adjacente vacío para moverse. Drag hacia un jugador para atacar. Y click en un jugador atacado para producir un SNARK de defensa.

Demo de juego

En el juego, haz click para spwanear, drag a un espacio vacío adjacente para moverte, drag a un oponente para atacar, click para defender

Thanks for reading this guide!

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

💖 💪 🙅 🚩
turupawn
Ahmed Castro

Posted on August 20, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related