ZK 🤝 Autonomous Worlds: Guide on Creating Privacy On-Chain Games
Ahmed Castro
Posted on July 29, 2024
Zero Knowledge solves the problem of any type of game requiring privacy to be fully played on-chain. For example playing poker on-chain by enabling players to keep their hands private.
In this tutorial, we'll implement Minesweeper on-chain. A Game Master will hide bombs on a map, and if a player steps on one, the Game Master can prove it, causing the player to "explode".
We will learn how to create a game where bombs are hidden on a private ZK state, if a player steps into one it will "explode"
This tutorial is part of a three-part series on ZK and autonomous worlds. If you haven't yet, be sure to check out my previous posts.
Table of contents
- Create a new MUD project
- 1. The state
- 2. The circuit
- 3. The contracts
- 4. The client
- 5. Bind everything together
- 6. The prover
- Going forward
Create a new MUD project
We'll be using Node 20 (>=18 should be ok), pnpm and foundry. Here are the commands if you haven't installed them yet.
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
Once installed create a new MUD project.
pnpm create mud@latest tutorial
cd tutorial
During the installation process select phaser.
1. Define the public sate
Everyone will be able to see each other's positions on-chain and publicly. However bomb positions will be stored on-chain, a commitment and a verifier contract will be stored on chain to prove the validity of the private computation.
packages/contracts/mud.config.ts
import { defineWorld } from "@latticexyz/world";
export default defineWorld({
namespace: "app",
enums: {
Direction: [
"Up",
"Down",
"Left",
"Right"
]
},
tables: {
Player: {
schema: {
player: "address",
x: "int32",
y: "int32",
isDead: "bool",
},
key: ["player"]
},
ZKState: {
schema: {
bombsCommitment: "uint32",
circomVerifier: "address"
},
key: [],
},
}
});
2. Compute privately on the Circuit
The circuit will handle all the private computation, for this tutorial we'll use Circom.
Let's create the ZK folder structure, following a very similar file structure of a typical Mud project.
mkdir packages/zk
mkdir packages/zk/circuits
mkdir packages/zk/prover
mkdir packages/zk/prover/zk_artifacts
The detonateBomb
circuit is capable of producing proofs that a player stepped into one of three bombs secretly placed on the map without revealing the the rest of the bombs. This is done by returning 1
as the result
signal if a player steped into one, otherwise 0
is returned. Notice the commitment
signal, this is the hash of all the bombs position. This will be later stored on chain to make sure the Game Master can't modify the bomb positions later on the game.
packages/zk/circuits/detonateBomb.circom
pragma circom 2.0.0;
include "circomlib/circuits/poseidon.circom";
include "circomlib/circuits/comparators.circom";
template commitmentHasher() {
signal input bomb1_x;
signal input bomb1_y;
signal input bomb2_x;
signal input bomb2_y;
signal input bomb3_x;
signal input bomb3_y;
signal output commitment;
component poseidonComponent;
poseidonComponent = Poseidon(6);
poseidonComponent.inputs[0] <== bomb1_x;
poseidonComponent.inputs[1] <== bomb1_y;
poseidonComponent.inputs[2] <== bomb2_x;
poseidonComponent.inputs[3] <== bomb2_y;
poseidonComponent.inputs[4] <== bomb3_x;
poseidonComponent.inputs[5] <== bomb3_y;
commitment <== poseidonComponent.out;
}
template detonateBomb() {
signal input bomb1_x;
signal input bomb1_y;
signal input bomb2_x;
signal input bomb2_y;
signal input bomb3_x;
signal input bomb3_y;
signal input player_x;
signal input player_y;
signal output commitment;
signal output result;
component commitmentHasherComponent;
commitmentHasherComponent = commitmentHasher();
commitmentHasherComponent.bomb1_x <== bomb1_x;
commitmentHasherComponent.bomb1_y <== bomb1_y;
commitmentHasherComponent.bomb2_x <== bomb2_x;
commitmentHasherComponent.bomb2_y <== bomb2_y;
commitmentHasherComponent.bomb3_x <== bomb3_x;
commitmentHasherComponent.bomb3_y <== bomb3_y;
commitment <== commitmentHasherComponent.commitment;
// Comparators
signal check_bomb1_x, check_bomb1_y;
signal check_bomb2_x, check_bomb2_y;
signal check_bomb3_x, check_bomb3_y;
check_bomb1_x <== bomb1_x - player_x;
check_bomb1_y <== bomb1_y - player_y;
check_bomb2_x <== bomb2_x - player_x;
check_bomb2_y <== bomb2_y - player_y;
check_bomb3_x <== bomb3_x - player_x;
check_bomb3_y <== bomb3_y - player_y;
// Check if any of the comparisons are zero
component isz_bomb1_x = IsZero();
component isz_bomb1_y = IsZero();
component isz_bomb2_x = IsZero();
component isz_bomb2_y = IsZero();
component isz_bomb3_x = IsZero();
component isz_bomb3_y = IsZero();
isz_bomb1_x.in <== check_bomb1_x;
isz_bomb1_y.in <== check_bomb1_y;
isz_bomb2_x.in <== check_bomb2_x;
isz_bomb2_y.in <== check_bomb2_y;
isz_bomb3_x.in <== check_bomb3_x;
isz_bomb3_y.in <== check_bomb3_y;
// Aggregate results
signal match_a, match_b, match_c;
signal match_any;
match_a <== isz_bomb1_x.out * isz_bomb1_y.out;
match_b <== isz_bomb2_x.out * isz_bomb2_y.out;
match_c <== isz_bomb3_x.out * isz_bomb3_y.out;
match_any <== match_a + match_b + match_c;
component isz_final = IsZero();
isz_final.in <== 1 - match_any;
isz_final.out ==> result;
log(result);
log(commitment);
}
component main {public [player_x, player_y]} = detonateBomb();
This circuit uses the Poseidon
hash and the IsZero
comparator libraries. You will need to install them as dependencies.
cd packages/zk/circuits
git clone https://github.com/iden3/circomlib.git
Now create an input file to test if everything works fine.
packages/zk/circuits/input.json
{
"bomb1_x": 1,
"bomb1_y": 1,
"bomb2_x": 2,
"bomb2_y": 2,
"bomb3_x": 2,
"bomb3_y": 3,
"player_x": 2,
"player_y": 3
}
Compile and generate a proof.
circom detonateBomb.circom --r1cs --wasm --sym
node detonateBomb_js/generate_witness.js detonateBomb_js/detonateBomb.wasm input.json witness.wtns
With the given input the result is equal to 1
because the player stepped into the 2,3
bomb. Also, the commitment is prompted on the terminal, copy paste it somewhere, we'll need it later on the tutorial.
Result:
1
Bombs commitment:
8613278371666841974523698252941148485158405612101680617360618409530277878563
We're using Groth16
for this demo, now run the trusted setup.
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="First contribution" -v
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v
snarkjs groth16 setup detonateBomb.r1cs pot12_final.ptau detonateBomb_0000.zkey
snarkjs zkey contribute detonateBomb_0000.zkey detonateBomb_0001.zkey --name="1st Contributor Name" -v
snarkjs zkey export verificationkey detonateBomb_0001.zkey verification_key.json
snarkjs zkey export solidityverifier detonateBomb_0001.zkey ../../contracts/src/CircomVerifier.sol
Finally, place the artifacts on the prover directory that will be used by the end of this tutorial.
cp detonateBomb_js/detonateBomb.wasm ../prover/zk_artifacts/
cp detonateBomb_0001.zkey ../prover/zk_artifacts/detonateBomb_final.zkey
3. Spawn, move and detonate bombs on Solidity
Go back to the project root.
cd ../../../
Remove non needed files.
rm packages/contracts/src/systems/IncrementSystem.sol packages/contracts/test/CounterTest.t.sol
The game system allows regular players to execute spawn
and move
and the Game Master to detonateBomb
s by passing as parameter a ZK proof. Notice we also emit an event when the player moves, this will become handy later on, when we build the ZK prover indexer.
packages/contracts/src/systems/MyGameSystem.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;
import { System } from "@latticexyz/world/src/System.sol";
import { Player, PlayerData, ZKState } from "../codegen/index.sol";
import { Direction } from "../codegen/common.sol";
import { getKeysWithValue } from "@latticexyz/world-modules/src/modules/keyswithvalue/getKeysWithValue.sol";
import { EncodedLengths, EncodedLengthsLib } from "@latticexyz/store/src/EncodedLengths.sol";
interface ICircomVerifier {
function verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals) external view returns (bool);
}
contract MyGameSystem is System {
function spawn(int32 x, int32 y) public {
address playerAddress = _msgSender();
Player.set(playerAddress, x, y, false);
}
function move(Direction direction) public {
address playerAddress = _msgSender();
PlayerData memory player = Player.get(playerAddress);
require(!player.isDead, "Player is dead");
int32 x = player.x;
int32 y = player.y;
if(direction == Direction.Up)
y-=1;
if(direction == Direction.Down)
y+=1;
if(direction == Direction.Left)
x-=1;
if(direction == Direction.Right)
x+=1;
require(x>= -31 && x<= 31 && y>= -31 && y<= 31, "Invalid position");
Player.setX(playerAddress, x);
Player.setY(playerAddress, y);
}
function detonateBomb(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals, address playerAddress) public {
ICircomVerifier(ZKState.getCircomVerifier()).verifyProof(_pA, _pB, _pC, _pubSignals);
uint32 commitment = uint32(_pubSignals[0]);
uint32 result = uint32(_pubSignals[1]);
int32 guessX = int32(uint32(uint(_pubSignals[2])));
int32 guessY = int32(uint32(uint(_pubSignals[3])));
PlayerData memory player = Player.get(playerAddress);
require(!player.isDead, "Player already dead");
require(result == 1, "No bomb in this position");
require(player.x == guessX && player.y == guessY, "Invalid position ");
uint32 bombsCommitment = ZKState.getBombsCommitment();
require(uint32(uint(commitment)) == bombsCommitment, "Invalid commitment");
Player.setIsDead(playerAddress, true);
}
}
When deploying, the GM will need to store the bomb commitment on-chain so he can't change it later on. Also notice we deploy the Groth16Verifier
contract that was generated on step 2.
packages/contracts/script/PostDeploy.s.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;
import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";
import { IWorld } from "../src/codegen/world/IWorld.sol";
import { ZKState } from "../src/codegen/index.sol";
import { Groth16Verifier } from "../src/CircomVerifier.sol";
contract PostDeploy is Script {
function run(address worldAddress) external {
StoreSwitch.setStoreAddress(worldAddress);
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
uint32 bombsCommitment = uint32(uint(8613278371666841974523698252941148485158405612101680617360618409530277878563));
address circomVerifier = address(new Groth16Verifier());
ZKState.set(bombsCommitment, circomVerifier);
vm.stopBroadcast();
}
}
4. Interact with the users via Phaser
Phaser is the game framework that facilitates animations, key presses, sounds, events. We define those on our Game System.
packages/client/src/layers/phaser/systems/myGameSystem.ts
import { Has, defineEnterSystem, defineSystem, defineExitSystem, getComponentValueStrict } from "@latticexyz/recs";
import { PhaserLayer } from "../createPhaserLayer";
import {
pixelCoordToTileCoord,
tileCoordToPixelCoord
} from "@latticexyz/phaserx";
import { TILE_WIDTH, TILE_HEIGHT, Animations, Directions } from "../constants";
export const createMyGameSystem = (layer: PhaserLayer) => {
const {
world,
networkLayer: {
components: {
Player
},
systemCalls: {
spawn,
move
}
},
scenes: {
Main: {
objectPool,
input
}
}
} = layer;
let toggle = false;
input.pointerdown$.subscribe((event) => {
const x = event.pointer.worldX;
const y = event.pointer.worldY;
const player = pixelCoordToTileCoord({ x, y }, TILE_WIDTH, TILE_HEIGHT);
if(player.x == 0 && player.y == 0)
return;
spawn(player.x, player.y)
});
input.onKeyPress((keys) => keys.has("W"), () => {
move(Directions.UP);
});
input.onKeyPress((keys) => keys.has("S"), () => {
move(Directions.DOWN);
});
input.onKeyPress((keys) => keys.has("A"), () => {
move(Directions.LEFT);
});
input.onKeyPress((keys) => keys.has("D"), () => {
move(Directions.RIGHT);
});
input.onKeyPress((keys) => keys.has("I"), () => {
// This should not be on the client, just for demo purposes
let bombSprite1 = objectPool.get("Bomb1", "Sprite");
let bombSprite2 = objectPool.get("Bomb2", "Sprite");
let bombSprite3 = objectPool.get("Bomb3", "Sprite");
if (toggle == true) {
bombSprite1.setComponent({
id: "position",
once: (sprite1) => {
sprite1.setVisible(false);
}
})
bombSprite2.setComponent({
id: "position",
once: (sprite2) => {
sprite2.setVisible(false);
}
})
bombSprite3.setComponent({
id: "position",
once: (sprite3) => {
sprite3.setVisible(false);
}
})
} else {
bombSprite1.setComponent({
id: 'animation',
once: (sprite1) => {
sprite1.setVisible(true);
sprite1.play(Animations.Bomb);
sprite1.setPosition(1*32, 1*32);
}
})
bombSprite2.setComponent({
id: 'animation',
once: (sprite2) => {
sprite2.setVisible(true);
sprite2.play(Animations.Bomb);
sprite2.setPosition(2*32, 2*32);
}
})
bombSprite3.setComponent({
id: 'animation',
once: (sprite3) => {
sprite3.setVisible(true);
sprite3.play(Animations.Bomb);
sprite3.setPosition(2*32, 3*32);
}
})
}
toggle = !toggle;
});
defineEnterSystem(world, [Has(Player)], ({entity}) => {
const playerObj = objectPool.get(entity, "Sprite");
playerObj.setComponent({
id: 'animation',
once: (sprite) => {
sprite.play(Animations.Player);
}
})
});
defineSystem(world, [Has(Player)], ({ entity }) => {
const player = getComponentValueStrict(Player, entity);
const pixelPosition = tileCoordToPixelCoord(player, TILE_WIDTH, TILE_HEIGHT);
const playerObj = objectPool.get(entity, "Sprite");
if(player.isDead)
{
playerObj.setComponent({
id: 'animation',
once: (sprite) => {
sprite.play(Animations.Dead);
}
})
}
playerObj.setComponent({
id: "position",
once: (sprite) => {
sprite.setPosition(pixelPosition.x, pixelPosition.y);
}
})
})
};
Now add the images on the sprites
directory, separated by folder, with sequential names.
packages/art/sprites/player/1.png
packages/art/sprites/player/2.png
packages/art/sprites/bomb/1.png
packages/art/sprites/dead/1.png
And build the tileset.
cd packages/art
yarn
yarn generate-multiatlas-sprites
Finally, define the animation images, names, duration and behavior.
packages/client/src/layers/phaser/configurePhaser.ts
import Phaser from "phaser";
import {
defineSceneConfig,
AssetType,
defineScaleConfig,
defineMapConfig,
defineCameraConfig,
} from "@latticexyz/phaserx";
import worldTileset from "../../../public/assets/tilesets/world.png";
import { TileAnimations, Tileset } from "../../artTypes/world";
import { Assets, Maps, Scenes, TILE_HEIGHT, TILE_WIDTH, Animations } from "./constants";
const ANIMATION_INTERVAL = 200;
const mainMap = defineMapConfig({
chunkSize: TILE_WIDTH * 64, // tile size * tile amount
tileWidth: TILE_WIDTH,
tileHeight: TILE_HEIGHT,
backgroundTile: [Tileset.Grass],
animationInterval: ANIMATION_INTERVAL,
tileAnimations: TileAnimations,
layers: {
layers: {
Background: { tilesets: ["Default"] },
Foreground: { tilesets: ["Default"] },
},
defaultLayer: "Background",
},
});
export const phaserConfig = {
sceneConfig: {
[Scenes.Main]: defineSceneConfig({
assets: {
[Assets.Tileset]: {
type: AssetType.Image,
key: Assets.Tileset,
path: worldTileset,
},
[Assets.MainAtlas]: {
type: AssetType.MultiAtlas,
key: Assets.MainAtlas,
// Add a timestamp to the end of the path to prevent caching
path: `/assets/atlases/atlas.json?timestamp=${Date.now()}`,
options: {
imagePath: "/assets/atlases/",
},
},
},
maps: {
[Maps.Main]: mainMap,
},
sprites: {
},
animations: [
{
key: Animations.Player,
assetKey: Assets.MainAtlas,
startFrame: 1,
endFrame: 2,
frameRate: 3,
repeat: -1,
prefix: "sprites/player/",
suffix: ".png",
},
{
key: Animations.Dead,
assetKey: Assets.MainAtlas,
startFrame: 1,
endFrame: 1,
frameRate: 12,
repeat: -1,
prefix: "sprites/dead/",
suffix: ".png",
},
{
key: Animations.Bomb,
assetKey: Assets.MainAtlas,
startFrame: 1,
endFrame: 1,
frameRate: 12,
repeat: -1,
prefix: "sprites/bomb/",
suffix: ".png",
},
],
tilesets: {
Default: {
assetKey: Assets.Tileset,
tileWidth: TILE_WIDTH,
tileHeight: TILE_HEIGHT,
},
},
}),
},
scale: defineScaleConfig({
parent: "phaser-game",
zoom: 1,
mode: Phaser.Scale.NONE,
}),
cameraConfig: defineCameraConfig({
pinchSpeed: 1,
wheelSpeed: 1,
maxZoom: 3,
minZoom: 1,
}),
cullingChunkSize: TILE_HEIGHT * 16,
};
5. Bind everything together
Setup all the variables and functions that the client needs in order to have visibility of web3 functions defined on the contracts.
packages/client/src/layers/phaser/constants.ts
export enum Scenes {
Main = "Main",
}
export enum Maps {
Main = "Main",
}
export enum Animations {
Player = "Player",
Dead = "Dead",
Bomb = "Bomb",
}
export enum Directions {
UP = 0,
DOWN = 1,
LEFT = 2,
RIGHT = 3,
}
export enum Assets {
MainAtlas = "MainAtlas",
Tileset = "Tileset",
}
export const TILE_HEIGHT = 32;
export const TILE_WIDTH = 32;
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);
};
packages/client/src/mud/createSystemCalls.ts
import { getComponentValue } from "@latticexyz/recs";
import { ClientComponents } from "./createClientComponents";
import { SetupNetworkResult } from "./setupNetwork";
import { singletonEntity } from "@latticexyz/store-sync/recs";
export type SystemCalls = ReturnType<typeof createSystemCalls>;
export function createSystemCalls(
{ worldContract, waitForTransaction }: SetupNetworkResult,
{ Player }: ClientComponents,
) {
const spawn = async (x: number, y: number) => {
const tx = await worldContract.write.app__spawn([x, y]);
await waitForTransaction(tx);
return getComponentValue(Player, singletonEntity);
};
const move = async (direction: number) => {
const tx = await worldContract.write.app__move([direction]);
await waitForTransaction(tx);
return getComponentValue(Player, singletonEntity);
}
return {
spawn, move
};
}
We're ready now to launch the server. Go back to the root folder and deploy it.
cd ../../
pnpm dev
6. Run the prover backend
MUD has its own logging system (known as events in Solidity) where it emits a Store_SetRecord
by default every time a record is modified. This is very convenient for us because the tester will listen to all player movement events on-chain, and when it detects that a player has come into contact with a bomb, it will detonate it. This is possible because only the tester has knowledge of the hidden bombs.
Let's start by creating a new npm project and installing the dependencies.
cd packages/zk/prover/
npm init -y
npm install express ethers snarkjs
Now create your server logic node file. We'll use a very simple express
server, ethersjs
will take care of all the web3 requirements and snarkjs
will produce the proofs.
packages/zk/prover/server.js
const express = require('express');
const { ethers } = require('ethers');
const fs = require('fs');
const snarkjs = require('snarkjs');
const contractAddressWorld = "0x8d8b6b8414e1e3dcfd4168561b9be6bd3bf6ec4b";
const privateKey = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
const playerTableId = "0x74626170700000000000000000000000506c6179657200000000000000000000";
const bombPositions = JSON.parse(fs.readFileSync('bombs.json', 'utf-8'));
const app = express();
const PORT = 8080;
const provider = new ethers.JsonRpcProvider('http://localhost:8545');
const contractABI = [
"function detonateBomb(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals, address playerAddress)",
];
const contractABIWorld = [
"event Store_SetRecord(bytes32 indexed tableId, bytes32[] keyTuple, bytes staticData, bytes32 encodedLengths, bytes dynamicData)",
"function app__detonateBomb(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[4] calldata _pubSignals, address playerAddress)"
];
const wallet = new ethers.Wallet(privateKey, provider);
const contractWorld = new ethers.Contract(contractAddressWorld, contractABIWorld, wallet);
function decodeRecord(hexString) {
if (hexString.startsWith('0x')) {
hexString = hexString.slice(2);
}
const xHex = hexString.slice(0, 8);
const yHex = hexString.slice(8, 16);
const isDeadHex = hexString.slice(16, 18);
const x = parseInt(xHex, 16) | 0;
const y = parseInt(yHex, 16) | 0;
const isDead = parseInt(isDeadHex, 16) != 0;
return { x, y, isDead };
}
contractWorld.on("Store_SetRecord", async (tableId, keyTuple, staticData, encodedLengths, dynamicData) => {
if(tableId == playerTableId)
{
let decodedRecord = decodeRecord(staticData);
let player = '0x' + keyTuple[0].replace(/^0x000000000000000000000000/, '');
if(!decodedRecord.isDead)
{
await detonateBomb(player, decodedRecord.x, decodedRecord.y);
}
}
});
async function detonateBomb(player, x, y) {
console.log(`Player move to (${x}, ${y})`);
for (const bomb of bombPositions) {
if (""+bomb.x === ""+x && ""+bomb.y === ""+y) {
try {
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
{
bomb1_x: bombPositions[0].x,
bomb1_y: bombPositions[0].y,
bomb2_x: bombPositions[1].x,
bomb2_y: bombPositions[1].y,
bomb3_x: bombPositions[2].x,
bomb3_y: bombPositions[2].y,
player_x: x,
player_y: y
},
"./zk_artifacts/detonateBomb.wasm",
"./zk_artifacts/detonateBomb_final.zkey"
);
let pA = proof.pi_a;
pA.pop();
let pB = proof.pi_b;
pB.pop();
let pC = proof.pi_c;
pC.pop();
if (publicSignals[1] == "1") {
const tx = await contractWorld.app__detonateBomb(
pA,
pB,
pC,
publicSignals,
player
);
console.log('Transaction:', tx);
}
} catch (error) {
console.error("Error generating or verifying proof:", error);
}
}
}
}
app.get('/', (req, res) => {
res.send('Server is running');
});
app.listen(PORT, async () => {
console.log(`Server is listening on port ${PORT}`);
});
Notice you will need your Player
table tableId
. Grab it from the ui.
The private bombs data will be stored in a separate json file.
packages/zk/prover/bombs.json
[
{"x": 1, "y": 1},
{"x": 2, "y": 2},
{"x": 2, "y": 3}
]
To run your prover backend, you will need the MyGameSystem
and World
address, also a private key of a wallet with ETH.
Grab the World
address from your contract process after it finishes loading. Put it on the CONTRACT_ADDRESS_WORLD
environment variable.
Setup the contractAddressWorld
and playerTableId
variables now.
You are ready to start the prover now.
node server.js
If a player steps into a bomb the prover will detect it and detonate tbe bomb on-chain.
Going forward
If you are familiarized with on chain gaimming you might be wondering: Isn't a minesweeper game possible with a traditional commit-reveal scheme? And yes, that's correct, you can do a minesweeper by commiting to the bombs positions on a, for example, mapping(uint bombId => bytes32 commitment)
. However, while some commit-reveal complications can be solved by themselves, like for example apply modulus %
to the commitment to prevent brute force attacks, ZK introduces new capabilities where I see a lot of potential. Here I share some so you can get inspired by having a sense some of the posibilities.
1. Constrained commitments
When you post a normal commit there is no way of applying constraints to it. On ZK you can, for example define the boundaries of a map, be specific about the amount and caracteristics of the hidden data. For example, there is higher chance to find ore in the mountains or fish in a river. All of this can be defined on the circuit.
2. Data optimizations
ZK allows to reveal portions of a commitment. This means a smaller amount of data is posted on-chain. Yes, verifying zk proofs is expensive at execution level but on L2s execution is cheap and data is expensive, even with danksharding and plasma. That's why cheap execution environments such as Redstone are ideal for this type of games if you are ok with sacrificing a bit of security.
3. More ZK constructions
In this tutorial we went through a PvE scenario, where a player interacts with an environment in adversarial situations. Where all the private off-chain computation is provable and secured on-chain. Now imagine PvP scenarios, or other ZK constructions applied to other types of games.
On future guides I would like to explore other ZK constructions such as private item consuption or private actions by posting merkle inclusion proofs. So stay tuned!
Thanks for reading this guide!
Follow Filosofía Código on dev.to and in Youtube for everything related to Blockchain development.
Posted on July 29, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.