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
- Crea un proyecto de MUD
- 1. El estado
- 2. Los circuitos
- 2.a. SNARK de ataque
- 2.b. SNARK de defensa
- 2.c. Contratos verificadores
- 3. Los contratos
- 4. UI y Phaser
- 5. zk-WASM en el Cliente
- 6. Las animaciones
- 7. Un poco de carpintería
- 8. Corre el juego
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.
🐉 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.
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
Una vez instalados crea un nuevo proyecto de Mud.
pnpm create mud@latest tutorial
cd tutorial
Durante la instalación selecciona phaser.
1. Define el estado
Las posiciones de los personajes se mantienen públicas, definidas en las tablas. Adicionalmente agregamos unos commitment
s 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: [],
},
},
});
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();
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();
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
c. Crea los contratos verificadores
Ingresa en la carpeta del circuito de reveal.
cd reveal
Compila el circuito.
circom reveal.circom --r1cs --wasm --sym
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
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
Ahora haz lo mismo con el circuito de defensa.
cd ../defend
Compilamos.
circom defend.circom --r1cs --wasm --sym
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
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
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
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);
}
}
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();
}
}
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;
}
};
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
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
};
}
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/Attacked/1.png
packages/art/sprites/Dead/1.png
packages/art/sprites/Unknown/1.png
Generamos los el atlas.
cd packages/art
yarn
yarn generate-multiatlas-sprites
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,
};
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;
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);
};
8. Corre el juego
Regresamos a la carpeta raíz de nuestro proyecto.
cd ../../
Y corremos el juego.
pnpm dev
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.
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.