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
- Create a MUD project
- 1. The state
- 2. The circuits
- 2.a. Attack SNARK
- 2.b. Defense SNARK
- 2.c. Verifier contracts
- 3. The contracts
- 4. UI and Phaser
- 5. zk-WASM in the Client
- 6. Las animaciones
- 7. Bind everything together
- 8. Run the game
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.
🐉 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.
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
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: [],
},
},
});
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();
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();
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
c. Create the verifier contracts
Get into the reveal circuit folder.
cd reveal
Compile the circuit.
circom reveal.circom --r1cs --wasm --sym
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
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
Now do the same with the defense circuit.
cd ../defend
Compile.
circom defend.circom --r1cs --wasm --sym
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
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
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
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);
}
}
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();
}
}
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;
}
};
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
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
};
}
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/Attacked/1.png
packages/art/sprites/Dead/1.png
packages/art/sprites/Unknown/1.png
Generate the 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.
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,
};
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;
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. Run the game
Go back to the project's root directory.
cd ../../
And run the game.
pnpm dev
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.
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.