How to create an Information Asymmetric Autonomous World: MUD + Circom

Ahmed Castro - Aug 20 - - Dev Community

In this tutorial, we will create a 100% on-chain game with information asymmetry, in other words, an autonomous world where the private state and computation but remain fully verifiable on Ethereum. We will use MUD, the engine for Autonomous Worlds, and Circom, the most widely used language for ZK circuits.

You will learn to:

  • Combine the Circom language with the MUD framework
  • Create an autonomous world with private computation and variables
  • Generate zk-SNARK proofs directly from your browser using Snark.js

Table of contents

The game

At the start of the game, each player spawns 4 units that they can move across the map. Each unit is of a different type, but this information is private, visible only to the owner of the units.

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 🐉

The four defined types in the game are the 🐉, which beats the 🧙, which beats the 🧌, which beats the 🗡️. However, the 🗡️ is a special unit because it's the only one that can defeat the 🐉. Battles are done using a zk-SNARK that reveals the attacker's type, followed by another zk-SNARK that computes the battle's outcome without revealing the type of the unit that was attacked.

arquitectura de juego
The game's architecture places all public data and logic in MUD, while all privacy-related aspects are handled in Circom

Create a MUD project

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

1. Define the State

The characters' positions are kept public, defined in the MUD tables. Additionally, we add some ZK commitments to ensure that no one can cheat, this part will make more sense in the upcoming ZK section.

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. Create the combat circuits

The battle circuits consist of the attack SNARK and the defense SNARK.

a. Attack SNARK

When a character attacks, it reveals its type. To ensure the player isn't cheating, we create a SNARK that hashes the initial types defined by the player at the start of the game. The circuit ensures that the player has assigned a character of each type, and then everything is hashed together with a privateSalt, which acts as a private key and prevents brute-force attacks to uncover the initial state the player committed to. This hash is called a commitment, which is stored in a MUD table that will help verify that everything happened correctly. More details on this will be covered in the contracts section below.

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. Defense SNARK

When a character is attacked, it enters a state where it cannot move or attack. To exit this state, the character must present a SNARK that proves its defense was successful without revealing its type. This is achieved through a circuit that verifies the defense based on the public type of the attacker and the private type of the defender.

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

As you can see, we're using the Poseidon and comparator libraries, install them.

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

c. Create the verifier contracts

Get into the reveal circuit folder.

cd reveal
Enter fullscreen mode Exit fullscreen mode

Compile the circuit.

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

Generate the groth16 ceremony and verifier contract.

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

Put it in the MUD contract folder.

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

Now do the same with the defense circuit.

cd ../defend
Enter fullscreen mode Exit fullscreen mode

Compile.

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

And generate the verifier.

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

Put it in the contracts folder.

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

You should also change the generic name Groth16Verifier to RevealVerifier and DefendVerifier, respectively, in the contracts we just placed in packages/client/public/zk_artifacts/.

3. Game Logic

Delete a couple of files that we won't be using.

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

All the game logic is defined in Solidity. It includes the initial spawn, followed by the attack and defense phases, each with their respective ZK proofs. We also added a killUnresponsiveCharacter function, which eliminates a player if they fail to present their defense ZK proof within a given time.

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

Remember that in MUD, we don't use traditional constructors. This is because a single System contract can manage the state of multiple worlds. Instead, we use the PostDeploy contract, where we deploy the verifier contracts.

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. The Client with Phaser + Snarkjs

Create the file that will handle the user interface logic. This file manages mouse actions for dragging to move, connecting two characters to attack, and clicking to defend. Additionally, it defines the logic for animations.

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. Client-Ethereum and Client-SNARK interaction

First, we need to install the library that will help us produce SNARKs.

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

Now we can define the on-chain transactions and the generation of ZK proofs.

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. Adding Game Animations

You can use any animations you like, but if you want to use the same ones I'm using, you can download these assets:

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

Generate the 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.

Define the game animations. You can set the fames, speed and behaviour.

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. Bind everything together

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. Run the game

Go back to the project's root directory.

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

And run the game.

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Now you can open the game in two different browsers. Each player can spawn by clicking on an empty space. Drag to an adjacent empty space to move. Drag to another player to attack. Click on an attacked player to generate a defense SNARK.

Demo de juego

In the game, click to spawn, drag to an adjacent empty space to move, drag to an opponent to attack, and click to defend.

Thanks for reading this guide!

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

. . . . . . . . . . . . . . . . . . . . . . . . .