Spin up an onchain game with ZK Spin
Prerequirements:
Installation:
npm install -g spin-m4
Start a new project:
npx spin-m4 init <project-name>
Replace project-name with the name of the project.
Running this command spins up a scaffold project with the following directories:
frontend
It contains a Vite app in which the game will be built on.
Initially Counter app will be provided.
gameplay
It contains the provable logic of the game
onchain
It contains the hardhat project with contracts used in the scaffold app.
Let's dive into Building the Snake Game 🤿
cd <project-name>
npx spin-m4 build-image --path gameplay/provable_game_logic/
cd frontend
mv template.env .env
npm install
spin up the game client
npm run dev
Check out the default game
This is a scaffold game client with the following functionalities
Increment
- Increases the game state
Decrement
submit (Submit proof on-chain)
Let's create a contract for Snake game
cd onchain
mv template.env .env
Replace the following in GameContract.sol
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./SpinContract.sol";
contract GameContract is SpinContract {
/* Trustless Application Settlement Template */
constructor(address verifier_address) SpinContract(verifier_address) {}
/* Application On-chain Business Logic */
uint64 public x_position;
uint64 public y_position;
uint64 public highscore;
// Get the current state of the game contract
function getStates() external view returns (uint64, uint64,uint64) {
return (x_position, y_position,highscore);
}
struct ZKInput {
uint64 start_x_position;
uint64 start_y_position;
uint64 start_highscore;
}
struct ZKOutput {
uint64 end_x_position;
uint64 end_y_position;
uint64 end_highscore;
}
// Settle a verified proof
function settle(uint256[][] calldata instances) internal override {
// [[]]
ZKInput memory zk_input = ZKInput(uint64(instances[0][0]), uint64(instances[0][1]),uint64(instances[0][2]));
ZKOutput memory zk_output = ZKOutput(uint64(instances[0][3]), uint64(instances[0][4]),uint64(instances[0][5]));
require(
zk_input.start_x_position == x_position && zk_input.start_y_position == y_position && zk_input.start_highscore == highscore ,
"Invalid start state"
);
x_position = zk_output.end_x_position;
y_position = zk_output.end_y_position;
highscore = zk_output.end_highscore;
}
function DEV_ONLY_setStates(uint64 _x_position, uint64 _y_position, uint64 _highscore ) external onlyOwner {
x_position = _x_position;
y_position = _y_position;
highscore = _highscore;
}
}
We are gonna deploy the contract on Sepolia testnet.
Update the .env file in onchain folder with your own keys.
ETHERSCAN_API_KEY=""
SEPOLIA_RPC_URL="https://sepolia.infura.io/v3/..."
DEPLOYER_PRIVATE_KEY=""
DEPLOYER_PUBLIC_KEY=""
SEPOLIA_VERIFIER_ADDRESS="0x2cd0a24aCAC1ee774443A28BD28C46E2D8e3a091"
<contract deployment issue with hardhat>
So let's deploy using remix
Copy both SpinContract.sol and IVerifier.sol along with GameContract.sol
deploy it in Sepolia testnet
i.e contract address: 0xAa67Afb69f695993e39cEE620f423616dFc75cB9
Also verify the same in etherscan
Let's build provable game logic
cd gameplay
In "gameplay/provable_game_logic/src" folder
update definition.rs
use serde::{Deserialize, Serialize};
use std::fmt;
use wasm_bindgen::prelude::*;
#[derive(Clone, Serialize)]
#[wasm_bindgen]
pub struct SpinGameInitArgs {
// define your arguments here
pub x_position: u64,
pub y_position: u64,
pub highscore: u64,
}
#[wasm_bindgen]
impl SpinGameInitArgs {
#[wasm_bindgen(constructor)]
pub fn new(x_position: u64, y_position: u64, highscore: u64) -> SpinGameInitArgs {
SpinGameInitArgs {
x_position,
y_position,
highscore
}
}
}
#[derive(Clone, Serialize)]
#[wasm_bindgen]
pub struct SpinGameIntermediateStates {
// define your game state here
pub x_position: u64,
pub y_position: u64,
pub highscore: u64,
}
impl SpinGameIntermediateStates {
pub fn new() -> SpinGameIntermediateStates {
SpinGameIntermediateStates {
x_position: 0,
y_position: 0,
highscore: 0,
}
}
}
impl fmt::Display for SpinGameIntermediateStates {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"GameState {{ x_position: {}, y_position: {}, highscore: {} }}",
self.x_position, self.y_position, self.highscore
)
}
}
update gameplay.rs
use crate::definition::SpinGameInitArgs;
use crate::definition::SpinGameIntermediateStates;
use crate::spin::SpinGame;
use crate::spin::SpinGameTrait;
use once_cell::sync::Lazy;
use std::sync::Mutex;
use wasm_bindgen::prelude::*;
pub const MAX_POSITION: u64 = 10;
pub static GAME_STATE: Lazy<Mutex<SpinGameIntermediateStates>> =
Lazy::new(|| Mutex::new(SpinGameIntermediateStates::new()));
impl SpinGameTrait for SpinGame {
/* STATEFUL FUNCTIONS This defines the initialization of the game*/
fn initialize_game(args: SpinGameInitArgs) {
let mut game_state = GAME_STATE.lock().unwrap();
game_state.x_position = args.x_position;
game_state.y_position = args.y_position;
game_state.highscore = args.highscore;
}
/* STATEFUL FUNCTIONS This is defines the logic when player moves one step/entering one command*/
fn step(input: u64) {
let mut game_state = GAME_STATE.lock().unwrap();
match input {
0 => {
game_state.x_position +=1;
}
1 => {
game_state.y_position +=1 ;
}
2 => {
game_state.highscore +=1 ;
}
_ => {
panic!("Invalid command");
}
};
}
/* PURE FUNCTION This function returns the game state, to be used in Rust and Zkmain */
fn get_game_state() -> SpinGameIntermediateStates {
let game = GAME_STATE.lock().unwrap().clone();
return game;
}
}
npx spin-m4 build-image --path gameplay/provable_game_logic/
In gameplay/export/src
zkmain.rs
use provable_game_logic::definition::SpinGameInitArgs;
use provable_game_logic::definition::SpinGameIntermediateStates;
use provable_game_logic::spin::SpinGame;
use wasm_bindgen::prelude::*;
use zkwasm_rust_sdk::wasm_input;
use zkwasm_rust_sdk::wasm_output;
use provable_game_logic::spin::SpinGameTrait;
/*
PUBLIC INPUTS marked by `wasm_input`: e.g wasm_input(1) specifies a public input of type u64
PRIVATE INPUTS marked by `wasm_input`: e.g wasm_input(0) specifies a priva te input of type u64
PUBLIC OUTPUTS marked by `wasm_output`: e.g wasm_output(var) specifies an output `var` of type u64
*/
#[wasm_bindgen]
pub fn zkmain() -> i64 {
// specify the public inputs
let public_input_0_total_steps: u64 = unsafe { wasm_input(1) };
let public_input_1_current_position: u64 = unsafe { wasm_input(1) };
let public_input_1_highscore: u64 = unsafe { wasm_input(1) };
SpinGame::initialize_game(SpinGameInitArgs {
x_position: public_input_0_total_steps,
y_position: public_input_1_current_position,
highscore: public_input_1_highscore,
});
// specify the private inputs
let private_inputs_length = unsafe { wasm_input(0) };
for _i in 0..private_inputs_length {
let _private_input = unsafe { wasm_input(0) };
SpinGame::step(_private_input);
}
unsafe {
let final_game_state: SpinGameIntermediateStates = SpinGame::get_game_state();
zkwasm_rust_sdk::dbg!("final_game_state: {}\n", final_game_state);
// specify the output
wasm_output(final_game_state.x_position as u64);
wasm_output(final_game_state.y_position as u64);
wasm_output(final_game_state.highscore as u64);
}
0
}
In gameplay/export/spinjs/src
Spin.ts
import { GamePlay, SpinGameInitArgs } from "./GamePlay.js";
import { ethers } from "ethers";
import {
add_proving_taks,
load_proving_taks_util_result,
ProveCredentials,
} from "./Proof.js";
import { ZkWasmServiceHelper, ZkWasmUtil } from "zkwasm-service-helper";
interface SpinConstructor {
cloudCredentials: ProveCredentials;
}
/* This Class is used to facilated core gameplay and zk proving*/
export class Spin {
gamePlay: GamePlay;
cloudCredentials: ProveCredentials;
inputs: bigint[] = []; // public inputs
witness: bigint[] = []; // private inputs
/* Constructor */
constructor({ cloudCredentials }: SpinConstructor) {
this.cloudCredentials = cloudCredentials;
console.log("cloudCredentials = ", cloudCredentials);
this.gamePlay = new GamePlay();
}
private add_public_input(input: bigint) {
this.inputs.push(input);
}
private add_private_input(input: bigint) {
this.witness.push(input);
}
// ================================================================================================
// BELOW FUNCTIONS CAN BE AUTO-GENERATED
/* Step the game
* part of the private inputs
*/
step(command: bigint) {
this.gamePlay.step(BigInt(command));
this.add_private_input(command);
}
/* Get the current game state */
getGameState() {
return this.gamePlay.getGameState();
}
async initialize_import() {
await this.gamePlay.init();
}
initialize_game(arg: SpinGameInitArgs) {
this.add_public_input(arg.x_position);
this.add_public_input(arg.y_position);
this.add_public_input(arg.highscore);
this.gamePlay.init_game(arg);
// TODO: dynamic add public inputs
// args.map((a) => this.add_public_input(a));
}
async getGameID() {
const helper = new ZkWasmServiceHelper(
this.cloudCredentials.CLOUD_RPC_URL,
"",
""
);
let retryCount = 0;
while (retryCount < 3) {
try {
const imageInfo = await helper.queryImage(
this.cloudCredentials.IMAGE_HASH
);
if (!imageInfo || !imageInfo.checksum) {
throw Error("Image not found");
}
const imageCommitment =
commitmentUint8ArrayToVerifyInstanceBigInts(
imageInfo.checksum.x,
imageInfo.checksum.y
);
const gameID = ethers.solidityPackedKeccak256(
["uint256", "uint256", "uint256"],
[imageCommitment[0], imageCommitment[1], imageCommitment[2]]
);
return gameID;
} catch (error: any) {
console.error(`Caught error: ${error}`);
if (error.message.startsWith("Too many requests")) {
console.log(`Caught 429 error. Retrying in 5 seconds...`);
await new Promise((resolve) => setTimeout(resolve, 5000));
retryCount++;
} else {
throw error;
}
}
}
throw Error("Failed to get image commitment");
}
// ================================================================================================
async generateProof() {
const tasksInfo = await add_proving_taks(
this.inputs.map((i) => `${i}:i64`),
[
`${this.witness.length}:i64`,
...this.witness.map((m) => `${m}:i64`),
],
this.cloudCredentials
);
const task_id = tasksInfo.id;
const proof = await load_proving_taks_util_result(
task_id,
this.cloudCredentials
);
console.log("final proof = ", proof);
return proof;
}
/* Reset the game
* Keeping the same onReady callback and cloud credentials
*/
async reset() {
this.inputs = [];
this.witness = [];
this.gamePlay = new GamePlay();
await this.gamePlay.init();
}
}
function commitmentUint8ArrayToVerifyInstanceBigInts(
xUint8Array: Uint8Array,
yUint8Array: Uint8Array
) {
const xHexString = ZkWasmUtil.bytesToHexStrings(xUint8Array);
const yHexString = ZkWasmUtil.bytesToHexStrings(yUint8Array);
console.log("xHexString = ", xHexString);
console.log("yHexString = ", yHexString);
const verifyInstances = commitmentHexToHexString(
"0x" + xHexString[0].slice(2).padStart(64, "0"),
"0x" + yHexString[0].slice(2).padStart(64, "0")
);
console.log("verifyInstances = ", verifyInstances);
const verifyingBytes = ZkWasmUtil.hexStringsToBytes(verifyInstances, 32);
console.log("verifyingBytes = ", verifyingBytes);
const verifyingBigInts = ZkWasmUtil.bytesToBigIntArray(verifyingBytes);
console.log("verifyingBigInts = ", verifyingBigInts);
return verifyingBigInts;
}
/* This function is used to convert the commitment hex to hex string
* in the format of verifying instance
* @param x: x hex string
* @param y: y hex string
*/
function commitmentHexToHexString(x: string, y: string) {
const hexString1 = "0x" + x.slice(12, 66);
const hexString2 =
"0x" + y.slice(39) + "00000000000000000" + x.slice(2, 12);
const hexString3 = "0x" + y.slice(2, 39);
return [hexString1, hexString2, hexString3];
}
npx spin-m4 publish-image --path gameplay/provable_game_logic/
On executing the above command
thirumurugansivalingam@thirumurugans-Laptop snake-babu % npx spin-m4 publish-image --path gameplay/provable_game_logic/
Running Spin version 0.5.0
Publishing project...
Publishing wasm image at path: /Users/thirumurugansivalingam/Desktop/snake-babu/gameplay/export/wasm/gameplay_bg.wasm
md5 = 6a99cddcd0780d8e3b0c51a624eae44b
filename = gameplay_bg.wasm
fileSelected = <Buffer 00 61 73 6d 01 00 00 00 01 65 11 60 02 7f 7f 01 7f 60 03 7f 7f 7f 01 7f 60 02 7f 7f 00 60 01 7f 00 60 01 7f 01 7f 60 00 01 7f 60 01 7e 00 60 05 7f 7f ... 23199 more bytes>
info is: {
name: 'gameplay_bg.wasm',
image_md5: '6a99cddcd0780d8e3b0c51a624eae44b',
image: <Buffer 00 61 73 6d 01 00 00 00 01 65 11 60 02 7f 7f 01 7f 60 03 7f 7f 7f 01 7f 60 02 7f 7f 00 60 01 7f 00 60 01 7f 01 7f 60 00 01 7f 60 01 7e 00 60 05 7f 7f ... 23199 more bytes>,
user_address: '0xf7b267c190841c5cff482aeab0ed538bc410feff',
description_url: 'Lg4',
avator_url: '',
circuit_size: 22,
auto_submit_network_ids: [],
prove_payment_src: 'Default'
}
AxiosError: Request failed with status code 500
at settle (/usr/local/lib/node_modules/spin-m4/node_modules/axios/dist/node/axios.cjs:1983:12)
at IncomingMessage.handleStreamEnd (/usr/local/lib/node_modules/spin-m4/node_modules/axios/dist/node/axios.cjs:3085:11)
at IncomingMessage.emit (node:events:530:35)
at endReadableNT (node:internal/streams/readable:1696:12)
at process.processTicksAndRejections (node:internal/process/task_queues:82:21)
at Axios.request (/usr/local/lib/node_modules/spin-m4/node_modules/axios/dist/node/axios.cjs:4224:41)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5) {
code: 'ERR_BAD_RESPONSE',
config: {
transitional: {
silentJSONParsing: true,
forcedJSONParsing: true,
clarifyTimeoutError: false
},
adapter: [ 'xhr', 'http', 'fetch' ],
transformRequest: [ [Function: transformRequest] ],
transformResponse: [ [Function: transformResponse] ],
timeout: 0,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
maxContentLength: -1,
maxBodyLength: -1,
env: { FormData: [Function], Blob: [class Blob] },
validateStatus: [Function: validateStatus],
headers: Object [AxiosHeaders] {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'multipart/form-data; boundary=--------------------------656870889263918468358561',
'x-eth-signature': '0xe3020fe098aef05737a3e70d16e055d6a9ae6595f50f5af9cfc68ab598728f603647d88c5fe2fae2b36b22a88a4aecbd18c254197f99f9baf21200f0b2ebb6201b',
'User-Agent': 'axios/1.7.2',
'Content-Length': '24323',
'Accept-Encoding': 'gzip, compress, deflate, br'
},
method: 'post',
url: 'https://rpc.zkwasmhub.com:8090/setup',
data: FormData {
_overheadLength: 916,
_valueLength: 23351,
_valuesToMeasure: [],
writable: false,
readable: true,
dataSize: 0,
maxDataSize: 2097152,
pauseStreams: true,
_released: true,
_streams: [],
_currentStream: null,
_insideLoop: false,
_pendingNext: false,
_boundary: '--------------------------656870889263918468358561',
_events: [Object: null prototype],
_eventsCount: 3
}
},
request: <ref *1> ClientRequest {
_events: [Object: null prototype] {
abort: [Function (anonymous)],
aborted: [Function (anonymous)],
connect: [Function (anonymous)],
error: [Function (anonymous)],
socket: [Function (anonymous)],
timeout: [Function (anonymous)],
finish: [Function: requestOnFinish]
},
_eventsCount: 7,
_maxListeners: undefined,
outputData: [],
outputSize: 0,
writable: true,
destroyed: true,
_last: false,
chunkedEncoding: false,
shouldKeepAlive: true,
maxRequestsOnConnectionReached: false,
_defaultKeepAlive: true,
useChunkedEncodingByDefault: true,
sendDate: false,
_removedConnection: false,
_removedContLen: false,
_removedTE: false,
strictContentLength: false,
_contentLength: '24323',
_hasBody: true,
_trailer: '',
finished: true,
_headerSent: true,
_closed: true,
socket: TLSSocket {
_tlsOptions: [Object],
_secureEstablished: true,
_securePending: false,
_newSessionPending: false,
_controlReleased: true,
secureConnecting: false,
_SNICallback: null,
servername: 'rpc.zkwasmhub.com',
alpnProtocol: false,
authorized: true,
authorizationError: null,
encrypted: true,
_events: [Object: null prototype],
_eventsCount: 9,
connecting: false,
_hadError: false,
_parent: null,
_host: 'rpc.zkwasmhub.com',
_closeAfterHandlingError: false,
_readableState: [ReadableState],
_writableState: [WritableState],
allowHalfOpen: false,
_maxListeners: undefined,
_sockname: null,
_pendingData: null,
_pendingEncoding: '',
server: undefined,
_server: null,
ssl: [TLSWrap],
_requestCert: true,
_rejectUnauthorized: true,
timeout: 5000,
parser: null,
_httpMessage: null,
[Symbol(alpncallback)]: null,
[Symbol(res)]: [TLSWrap],
[Symbol(verified)]: true,
[Symbol(pendingSession)]: null,
[Symbol(async_id_symbol)]: -1,
[Symbol(kHandle)]: [TLSWrap],
[Symbol(lastWriteQueueSize)]: 0,
[Symbol(timeout)]: Timeout {
_idleTimeout: 5000,
_idlePrev: [TimersList],
_idleNext: [TimersList],
_idleStart: 1433,
_onTimeout: [Function: bound ],
_timerArgs: undefined,
_repeat: null,
_destroyed: false,
[Symbol(refed)]: false,
[Symbol(kHasPrimitive)]: false,
[Symbol(asyncId)]: 28,
[Symbol(triggerId)]: 26
},
[Symbol(kBuffer)]: null,
[Symbol(kBufferCb)]: null,
[Symbol(kBufferGen)]: null,
[Symbol(shapeMode)]: true,
[Symbol(kCapture)]: false,
[Symbol(kSetNoDelay)]: false,
[Symbol(kSetKeepAlive)]: true,
[Symbol(kSetKeepAliveInitialDelay)]: 1,
[Symbol(kBytesRead)]: 0,
[Symbol(kBytesWritten)]: 0,
[Symbol(connect-options)]: [Object]
},
_header: 'POST /setup HTTP/1.1\r\n' +
'Accept: application/json, text/plain, */*\r\n' +
'Content-Type: multipart/form-data; boundary=--------------------------656870889263918468358561\r\n' +
'x-eth-signature: 0xe3020fe098aef05737a3e70d16e055d6a9ae6595f50f5af9cfc68ab598728f603647d88c5fe2fae2b36b22a88a4aecbd18c254197f99f9baf21200f0b2ebb6201b\r\n' +
'User-Agent: axios/1.7.2\r\n' +
'Content-Length: 24323\r\n' +
'Accept-Encoding: gzip, compress, deflate, br\r\n' +
'Host: rpc.zkwasmhub.com:8090\r\n' +
'Connection: keep-alive\r\n' +
'\r\n',
_keepAliveTimeout: 0,
_onPendingData: [Function: nop],
agent: Agent {
_events: [Object: null prototype],
_eventsCount: 2,
_maxListeners: undefined,
defaultPort: 443,
protocol: 'https:',
options: [Object: null prototype],
requests: [Object: null prototype] {},
sockets: [Object: null prototype] {},
freeSockets: [Object: null prototype],
keepAliveMsecs: 1000,
keepAlive: true,
maxSockets: Infinity,
maxFreeSockets: 256,
scheduling: 'lifo',
maxTotalSockets: Infinity,
totalSocketCount: 1,
maxCachedSessions: 100,
_sessionCache: [Object],
[Symbol(shapeMode)]: false,
[Symbol(kCapture)]: false
},
socketPath: undefined,
method: 'POST',
maxHeaderSize: undefined,
insecureHTTPParser: undefined,
joinDuplicateHeaders: undefined,
path: '/setup',
_ended: true,
res: IncomingMessage {
_events: [Object],
_readableState: [ReadableState],
_maxListeners: undefined,
socket: null,
httpVersionMajor: 1,
httpVersionMinor: 1,
httpVersion: '1.1',
complete: true,
rawHeaders: [Array],
rawTrailers: [],
joinDuplicateHeaders: undefined,
aborted: false,
upgrade: false,
url: '',
method: null,
statusCode: 500,
statusMessage: 'Internal Server Error',
client: [TLSSocket],
_consuming: false,
_dumped: false,
req: [Circular *1],
_eventsCount: 4,
responseUrl: 'https://rpc.zkwasmhub.com:8090/setup',
redirects: [],
[Symbol(shapeMode)]: true,
[Symbol(kCapture)]: false,
[Symbol(kHeaders)]: [Object],
[Symbol(kHeadersCount)]: 12,
[Symbol(kTrailers)]: null,
[Symbol(kTrailersCount)]: 0
},
aborted: false,
timeoutCb: null,
upgradeOrConnect: false,
parser: null,
maxHeadersCount: null,
reusedSocket: false,
host: 'rpc.zkwasmhub.com',
protocol: 'https:',
_redirectable: Writable {
_events: [Object],
_writableState: [WritableState],
_maxListeners: undefined,
_options: [Object],
_ended: true,
_ending: true,
_redirectCount: 0,
_redirects: [],
_requestBodyLength: 24323,
_requestBodyBuffers: [],
_eventsCount: 3,
_onNativeResponse: [Function (anonymous)],
_currentRequest: [Circular *1],
_currentUrl: 'https://rpc.zkwasmhub.com:8090/setup',
[Symbol(shapeMode)]: true,
[Symbol(kCapture)]: false
},
[Symbol(shapeMode)]: false,
[Symbol(kCapture)]: false,
[Symbol(kBytesWritten)]: 0,
[Symbol(kNeedDrain)]: true,
[Symbol(corked)]: 0,
[Symbol(kOutHeaders)]: [Object: null prototype] {
accept: [Array],
'content-type': [Array],
'x-eth-signature': [Array],
'user-agent': [Array],
'content-length': [Array],
'accept-encoding': [Array],
host: [Array]
},
[Symbol(errored)]: null,
[Symbol(kHighWaterMark)]: 16384,
[Symbol(kRejectNonStandardBodyWrites)]: false,
[Symbol(kUniqueHeaders)]: null
},
response: {
status: 500,
statusText: 'Internal Server Error',
headers: Object [AxiosHeaders] {
'alt-svc': 'h3=":8090"; ma=2592000',
'content-length': '84',
'content-type': 'text/plain; charset=utf-8',
date: 'Wed, 07 Aug 2024 14:49:48 GMT',
server: 'Caddy',
vary: 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers'
},
config: {
transitional: [Object],
adapter: [Array],
transformRequest: [Array],
transformResponse: [Array],
timeout: 0,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
maxContentLength: -1,
maxBodyLength: -1,
env: [Object],
validateStatus: [Function: validateStatus],
headers: [Object [AxiosHeaders]],
method: 'post',
url: 'https://rpc.zkwasmhub.com:8090/setup',
data: [FormData]
},
request: <ref *1> ClientRequest {
_events: [Object: null prototype],
_eventsCount: 7,
_maxListeners: undefined,
outputData: [],
outputSize: 0,
writable: true,
destroyed: true,
_last: false,
chunkedEncoding: false,
shouldKeepAlive: true,
maxRequestsOnConnectionReached: false,
_defaultKeepAlive: true,
useChunkedEncodingByDefault: true,
sendDate: false,
_removedConnection: false,
_removedContLen: false,
_removedTE: false,
strictContentLength: false,
_contentLength: '24323',
_hasBody: true,
_trailer: '',
finished: true,
_headerSent: true,
_closed: true,
socket: [TLSSocket],
_header: 'POST /setup HTTP/1.1\r\n' +
'Accept: application/json, text/plain, */*\r\n' +
'Content-Type: multipart/form-data; boundary=--------------------------656870889263918468358561\r\n' +
'x-eth-signature: 0xe3020fe098aef05737a3e70d16e055d6a9ae6595f50f5af9cfc68ab598728f603647d88c5fe2fae2b36b22a88a4aecbd18c254197f99f9baf21200f0b2ebb6201b\r\n' +
'User-Agent: axios/1.7.2\r\n' +
'Content-Length: 24323\r\n' +
'Accept-Encoding: gzip, compress, deflate, br\r\n' +
'Host: rpc.zkwasmhub.com:8090\r\n' +
'Connection: keep-alive\r\n' +
'\r\n',
_keepAliveTimeout: 0,
_onPendingData: [Function: nop],
agent: [Agent],
socketPath: undefined,
method: 'POST',
maxHeaderSize: undefined,
insecureHTTPParser: undefined,
joinDuplicateHeaders: undefined,
path: '/setup',
_ended: true,
res: [IncomingMessage],
aborted: false,
timeoutCb: null,
upgradeOrConnect: false,
parser: null,
maxHeadersCount: null,
reusedSocket: false,
host: 'rpc.zkwasmhub.com',
protocol: 'https:',
_redirectable: [Writable],
[Symbol(shapeMode)]: false,
[Symbol(kCapture)]: false,
[Symbol(kBytesWritten)]: 0,
[Symbol(kNeedDrain)]: true,
[Symbol(corked)]: 0,
[Symbol(kOutHeaders)]: [Object: null prototype],
[Symbol(errored)]: null,
[Symbol(kHighWaterMark)]: 16384,
[Symbol(kRejectNonStandardBodyWrites)]: false,
[Symbol(kUniqueHeaders)]: null
},
data: 'Image with md5 CaseInsensitiveMD5("6A99CDDCD0780D8E3B0C51A624EAE44B") already exists'
}
}
{
success: false,
error: {
code: 500,
message: 'Image with md5 CaseInsensitiveMD5("6A99CDDCD0780D8E3B0C51A624EAE44B") already exists'
}
}
Image already exists
https://rpc.zkwasmhub.com:8090/image
get queryImage response.
xHexString = [ '0xf33685ffa03d9bcf9b24b08787df8775ee1185fa5482d3a0a44cc27af384e91' ]
yHexString = [ '0x458eadcce4260f46d798bfc23db2449462bc5ac9207a12366baa31e437998c9' ]
verifyInstances = [
'0x03d9bcf9b24b08787df8775ee1185fa5482d3a0a44cc27af384e91',
'0x5ac9207a12366baa31e437998c9000000000000000000f33685ffa',
'0x0458eadcce4260f46d798bfc23db2449462bc'
]
verifyingBytes = Uint8Array(96) [
145, 78, 56, 175, 39, 204, 68, 10, 58, 45, 72, 165,
95, 24, 225, 94, 119, 248, 125, 120, 8, 75, 178, 249,
188, 217, 3, 0, 0, 0, 0, 0, 250, 95, 104, 51,
15, 0, 0, 0, 0, 0, 0, 0, 0, 144, 140, 153,
55, 228, 49, 170, 107, 54, 18, 122, 32, 201, 90, 0,
0, 0, 0, 0, 188, 98, 148, 68, 178, 61, 194, 191,
152, 215, 70, 15, 38, 228, 204, 173, 142, 69, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
]
verifyingBigInts = [
1584020191260679030564641136817635113041771370506407200001904273n,
37347050947054439772295065098667602850586590266501032772182237178n,
6059298845693464999705900950051965661110972n
]
--------------------
Record The Following Information:
Game ID: 0x71d80ed30e82c5aa4c11950d55a964121ff917fa1c2ccad0faaf2b3a1e9950a1
Image Hash 6a99cddcd0780d8e3b0c51a624eae44b
Image Commitments: [
1584020191260679030564641136817635113041771370506407200001904273n,
37347050947054439772295065098667602850586590266501032772182237178n,
6059298845693464999705900950051965661110972n
]
Copy the image hash from the response and update it in .env file in frontend
cd frontend
mv template.env .env
Replace the .env values in frontend
VITE_ZK_CLOUD_USER_ADDRESS="public address"
VITE_ZK_CLOUD_USER_PRIVATE_KEY="private key"
VITE_ZK_CLOUD_IMAGE_MD5="image hash from response"
VITE_GAME_CONTRACT_ADDRESS="deployed contract address"
VITE_ZK_CLOUD_URL="https://rpc.zkwasmhub.com:8090"
Replace the ABI.json with the new contract's ABI
Change the files (App.css, App.jsx, useInterval.tsx) from the gist
https://gist.github.com/Thirumurugan7/858853d493eb58038d349c61a17b391e
Download the assets (images) used in the game and paste it in the asset folder in frontend
App.tsx
explaination:
import React, { useEffect, useRef, useState } from 'react';
import "./App.css";
import { waitForTransactionReceipt, writeContract } from "@wagmi/core";
import { abi } from "./ABI.json";
import { config } from "./web3";
import { readContract } from "wagmi/actions";
import { TaskStatus } from "zkwasm-service-helper";
import AppleLogo from './assets/applePixels.png';
import Monitor from './assets/oldMonitor.png';
import useInterval from './hooks/useInterval';
import { Spin, SpinGameInitArgs } from "spin";
const GAME_CONTRACT_ADDRESS = import.meta.env.VITE_GAME_CONTRACT_ADDRESS;
const ZK_USER_ADDRESS = import.meta.env.VITE_ZK_CLOUD_USER_ADDRESS;
const ZK_USER_PRIVATE_KEY = import.meta.env.VITE_ZK_CLOUD_USER_PRIVATE_KEY;
const ZK_IMAGE_MD5 = import.meta.env.VITE_ZK_CLOUD_IMAGE_MD5;
const ZK_CLOUD_RPC_URL = import.meta.env.VITE_ZK_CLOUD_URL;
interface GameState {
x_position: bigint;
y_position: bigint;
highscore: bigint;
}
const canvasX = 1000;
const canvasY = 1000;
const initialSnake = [
[4, 10],
[4, 10],
];
const initialApple = [14, 10];
const scale = 50;
const timeDelay = 100;
/* This function is used to verify the proof on-chain */
async function verify_onchain({
proof,
verify_instance,
aux,
instances,
}: {
proof: BigInt[];
verify_instance: BigInt[];
aux: BigInt[];
instances: BigInt[];
status: TaskStatus;
}) {
console.log("proof", proof);
console.log("verify_instance", verify_instance);
console.log("aux",aux);
console.log("instance val passed", [instances]);
const result = await writeContract(config, {
abi,
address: GAME_CONTRACT_ADDRESS,
functionName: "settleProof",
args: [proof, verify_instance, aux, [instances]],
});
const transactionReceipt = waitForTransactionReceipt(config, {
hash: result,
});
return transactionReceipt;
}
/* This function is used to get the on-chain game states */
async function getOnchainGameStates() {
const result = (await readContract(config, {
abi,
address: GAME_CONTRACT_ADDRESS,
functionName: "getStates",
args: [],
})) as [bigint, bigint, bigint];
return result;
}
let spin: Spin;
function App() {
useEffect(() => {
getOnchainGameStates().then(async (result): Promise<any> => {
const x_position = result[0];
const y_position = result[1];
const highscore = result[2];
console.log("x_position = ", x_position);
console.log("y_position = ", y_position);
console.log("highscore = ", highscore);
setOnChainGameStates({
x_position,
y_position,
highscore
});
spin = new Spin({
cloudCredentials: {
CLOUD_RPC_URL: ZK_CLOUD_RPC_URL,
USER_ADDRESS: ZK_USER_ADDRESS,
USER_PRIVATE_KEY: ZK_USER_PRIVATE_KEY,
IMAGE_HASH: ZK_IMAGE_MD5,
},
});
spin.initialize_import().then(() => {
const arg = new SpinGameInitArgs( x_position,
y_position,
highscore);
console.log("arg = ", arg);
spin.initialize_game(arg);
updateDisplay();
});
});
}, []);
const [gameState, setGameState] = useState<GameState>({
x_position: BigInt(0),
y_position: BigInt(0),
highscore: BigInt(0)
});
const [onChainGameStates, setOnChainGameStates] = useState<GameState>({
x_position: BigInt(0),
y_position: BigInt(0),
highscore: BigInt(0)
});
const [moves, setMoves] = useState<bigint[]>([]);
const onClick = (command: bigint) => () => {
spin.step(command);
updateDisplay();
};
const updateDisplay = () => {
const newGameState = spin.getGameState();
setGameState({
x_position: newGameState.x_position,
y_position: newGameState.y_position,
highscore: newGameState.highscore,
});
setMoves(spin.witness);
};
// Submit the proof to the cloud
const submitProof = async () => {
console.log("valleded");
const proof = await spin.generateProof();
console.log("proof",proof);
if (!proof) {
console.error("Proof generation failed");
return;
}
// onchain verification operations
console.log("submitting proof");
try {
const verificationResult = await verify_onchain(proof);
console.log("verificationResult = ", verificationResult);
} catch (error) {
console.log("error in verify onchain function", error);
}
// wait for the transaction to be broadcasted, better way is to use event listener
await new Promise((r) => setTimeout(r, 1000));
const gameStates = await getOnchainGameStates();
setOnChainGameStates({
x_position: gameStates[0],
y_position: gameStates[1],
highscore: gameStates[2],
});
await spin.reset();
};
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [snake, setSnake] = useState(initialSnake);
const [apple, setApple] = useState(initialApple);
const [direction, setDirection] = useState([0, -1]);
const [delay, setDelay] = useState<number | null>(null);
const [gameOver, setGameOver] = useState(false);
const [score, setScore] = useState(0);
useInterval(() => runGame(), delay);
useEffect(() => {
let fruit = document.getElementById('fruit') as HTMLCanvasElement;
if (canvasRef.current) {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
ctx.fillStyle = '#a3d001';
snake.forEach(([x, y]) => ctx.fillRect(x, y, 1, 1));
ctx.drawImage(fruit, apple[0], apple[1], 1, 1);
}
}
}, [snake, apple, gameOver]);
function handleSetScore() {
if (score > Number(localStorage.getItem('snakeScore'))) {
localStorage.setItem('snakeScore', JSON.stringify(score));
}
}
function play() {
setSnake(initialSnake);
setApple(initialApple);
setDirection([1, 0]);
setDelay(timeDelay);
setScore(0);
setGameOver(false);
}
function checkCollision(head: number[]) {
for (let i = 0; i < snake.length; i++) {
if (head[i] < 0 || head[i] * scale >= canvasX) return true;
}
for (const s of snake) {
if (head[0] === s[0] && head[1] === s[1]) return true;
}
return false;
}
function appleAte(newSnake: number[][]) {
let coord = apple.map(() => Math.floor((Math.random() * canvasX) / scale));
if (newSnake[0][0] === apple[0] && newSnake[0][1] === apple[1]) {
let newApple = coord;
setScore(score + 1);
setApple(newApple);
return true;
}
return false;
}
function runGame() {
const newSnake = [...snake];
const newSnakeHead = [
newSnake[0][0] + direction[0],
newSnake[0][1] + direction[1],
];
newSnake.unshift(newSnakeHead);
if (checkCollision(newSnakeHead)) {
setDelay(null);
setGameOver(true);
handleSetScore();
onClick(BigInt(2))
submitProof()
}
if (!appleAte(newSnake)) {
newSnake.pop();
}
setSnake(newSnake);
}
function changeDirection(e: React.KeyboardEvent<HTMLDivElement>) {
switch (e.key) {
case 'ArrowLeft':
console.log("clicked left");
onClick(BigInt(0))
setDirection([-1, 0]);
break;
case 'ArrowUp':
console.log("clicked up");
onClick(BigInt(1))
setDirection([0, -1]);
break;
case 'ArrowRight':
console.log("clicked right");
onClick(BigInt(0))
setDirection([1, 0]);
break;
case 'ArrowDown':
console.log("clicked down");
onClick(BigInt(1))
setDirection([0, 1]);
break;
}
}
return (
<div className="App">
<div onKeyDown={e => changeDirection(e)}>
<img id="fruit" src={AppleLogo} alt="fruit" width="30" />
<img src={Monitor} alt="fruit" width="4000" className="monitor" />
<canvas
className="playArea"
ref={canvasRef}
width={`${canvasX}px`}
height={`${canvasY}px`}
/>
{gameOver && <div className="gameOver">Game Over</div>}
<button onClick={play} className="playButton">
Play
</button>
<div className="scoreBox">
<h2 className='val'>Score: {score}</h2>
<h2 className='val'>High Score: {localStorage.getItem('snakeScore')}</h2>
</div>
</div>
</div>
);
}
export default App;
Explanation:
import { Spin, SpinGameInitArgs } from "spin";
The Spin and SpinGameInitArgs are imported from the gameplay directory.
interface GameState { x_position: bigint; y_position: bigint; highscore: bigint; }
GameState interface contains the type of states used in the game.
useEffect(() => {
getOnchainGameStates().then(async (result): Promise<any> => {
const x_position = result[0];
const y_position = result[1];
const highscore = result[2];
console.log("x_position = ", x_position);
console.log("y_position = ", y_position);
console.log("highscore = ", highscore);
setOnChainGameStates({
x_position,
y_position,
highscore
});
spin = new Spin({
cloudCredentials: {
CLOUD_RPC_URL: ZK_CLOUD_RPC_URL,
USER_ADDRESS: ZK_USER_ADDRESS,
USER_PRIVATE_KEY: ZK_USER_PRIVATE_KEY,
IMAGE_HASH: ZK_IMAGE_MD5,
},
});
spin.initialize_import().then(() => {
const arg = new SpinGameInitArgs( x_position,
y_position,
highscore);
console.log("arg = ", arg);
spin.initialize_game(arg);
updateDisplay();
});
});
}, []);
This useEffect read the initial game states and initializes the spin instance
function play() {
setSnake(initialSnake);
setApple(initialApple);
setDirection([1, 0]);
setDelay(timeDelay);
setScore(0);
setGameOver(false);
}
Play function starts the game.
function changeDirection(e: React.KeyboardEvent<HTMLDivElement>) {
switch (e.key) {
case 'ArrowLeft':
console.log("clicked left");
onClick(BigInt(0))
setDirection([-1, 0]);
break;
case 'ArrowUp':
console.log("clicked up");
onClick(BigInt(1))
setDirection([0, -1]);
break;
case 'ArrowRight':
console.log("clicked right");
onClick(BigInt(0))
setDirection([1, 0]);
break;
case 'ArrowDown':
console.log("clicked down");
onClick(BigInt(1))
setDirection([0, 1]);
break;
}
}
The changeDirection function changes the game state od x_direction and y_direction.
function runGame() {
const newSnake = [...snake];
const newSnakeHead = [
newSnake[0][0] + direction[0],
newSnake[0][1] + direction[1],
];
newSnake.unshift(newSnakeHead);
if (checkCollision(newSnakeHead)) {
setDelay(null);
setGameOver(true);
handleSetScore();
onClick(BigInt(2))
submitProof()
}
if (!appleAte(newSnake)) {
newSnake.pop();
}
setSnake(newSnake);
}
The runGame function runs the game and triggers the settleProof function when the game ends.
const submitProof = async () => {
console.log("valleded");
const proof = await spin.generateProof();
console.log("proof",proof);
if (!proof) {
console.error("Proof generation failed");
return;
}
// onchain verification operations
console.log("submitting proof");
try {
const verificationResult = await verify_onchain(proof);
console.log("verificationResult = ", verificationResult);
} catch (error) {
console.log("error in verify onchain function", error);
}
submitProof function is calls the generateProof function from the step instance.
This proof contains the verifiable proof of the gamestate changes.
const verificationResult = await verify_onchain(proof);
verify_onchain() function calls the settleProof function from the gameContract.
Finally
Check out
Subscribe to my newsletter
Read articles from THIRUMURUGAN SIVALINGAM directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
THIRUMURUGAN SIVALINGAM
THIRUMURUGAN SIVALINGAM
Won 15+ Web3 Hackathon. Development Lead at Ultimate Digits. DevRel trying to improve Developer Experience. Loves teaching web3.