Skip to content

Tutorial Mover Part 2 Playing

Andy Colosimo edited this page Jun 12, 2026 · 1 revision

Tutorial: Mover on Polygon — Part 2: Playing

In Part 1 you built and synced your own Mover GSP against Polygon mainnet. In this part you actually play: you register a Xaya name (an ERC-721 NFT), send a real move transaction on Polygon, watch your GSP pick it up and process it, and read the resulting game state over JSON-RPC. The move transaction, GSP log lines, and RPC responses shown below are real outputs captured from a live run on Polygon mainnet.

Here is the round trip you are about to make:

sequenceDiagram
    participant You as Your wallet (script)
    participant XA as XayaAccounts contract (Polygon)
    participant XX as XayaX bridge
    participant GSP as moverd (your GSP)
    participant Curl as curl (you again)

    You->>XA: move("p", "myname", '{"g":{"mv":{"d":"k","n":2}}}', ...)
    XA-->>XX: block + move event
    XX-->>GSP: ZMQ block notification + moves
    GSP->>GSP: ProcessForward — apply the move
    Curl->>GSP: getcurrentstate (JSON-RPC)
    GSP-->>Curl: {"players":{"myname":{"x":0,"y":2}}}
Loading

Note the one-way data flow: you write to the chain with an ordinary wallet transaction, and the GSP only ever reads. The GSP never sends transactions.

1. What you need

Three things:

  1. A wallet private key. Any EVM wallet works — there is nothing Xaya-specific about the wallet. You can export the private key of an existing account from MetaMask (Account details → Show private key), or generate a fresh one just for this tutorial:

    node -e "import('viem/accounts').then(m => { const pk = m.generatePrivateKey(); console.log('key:    ' + pk); console.log('address: ' + m.privateKeyToAccount(pk).address); })"

    (This one-liner uses the viem package, so run it inside the scripts directory you will set up in the next section, after npm install viem.)

    A fresh throwaway key is the safer choice: you will be passing the key to scripts as an environment variable. Never use a key that controls funds you care about, and never commit a key to git.

  2. A little POL for gas. Moves on Polygon cost well under $0.01 each, so even a dollar's worth of POL lasts a very long time. Send some POL to your wallet address from an exchange or another wallet.

  3. 1 WCHI — only if you need to register a new name. Name registration costs a flat 1 WCHI (verified live against the registration policy contract). WCHI is an ERC-20 token on Polygon at:

    0xE79feAAA457ad7899357E8E2065a3267aC9eE601
    

    It has 8 decimals (so 1 WCHI = 100,000,000 base units). You can obtain WCHI on decentralized exchanges on Polygon. If you already own a Xaya name in the wallet you are using, you can skip registration entirely — one name can play any Xaya game.

You also need your GSP from Part 1 running and reporting "state": "up-to-date".

2. Set up a scripts directory

We will use two small Node.js scripts built on viem, a lightweight EVM client library. Create a directory and install the one dependency:

mkdir -p ~/mover-polygon/scripts
cd ~/mover-polygon/scripts
npm init -y
npm install viem

Now create the two script files. First, register-name.mjs:

#!/usr/bin/env node
// Register a Xaya name (p/ namespace) on Polygon.
//
// Usage:
//   PRIVATE_KEY=0x... node register-name.mjs <name>
//
// Registration costs WCHI (currently a flat 1 WCHI), paid by approving the
// XayaAccounts contract to spend it.  The wallet also needs a little POL
// for gas.  The name is minted to your wallet as an ERC-721 token.

import { createWalletClient, createPublicClient, http, formatUnits } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { polygon } from 'viem/chains';

const XAYA_ACCOUNTS = '0x8C12253F71091b9582908C8a44F78870Ec6F304F';
const POLYGON_RPC = process.env.POLYGON_RPC ?? 'https://polygon-node.xaya.io';

const accountsAbi = [
  { name: 'register', type: 'function', stateMutability: 'payable',
    inputs: [{ name: 'ns', type: 'string' }, { name: 'name', type: 'string' }],
    outputs: [{ type: 'uint256' }] },
  { name: 'exists', type: 'function', stateMutability: 'view',
    inputs: [{ name: 'ns', type: 'string' }, { name: 'name', type: 'string' }],
    outputs: [{ type: 'bool' }] },
  { name: 'policy', type: 'function', stateMutability: 'view',
    inputs: [], outputs: [{ type: 'address' }] },
  { name: 'wchiToken', type: 'function', stateMutability: 'view',
    inputs: [], outputs: [{ type: 'address' }] },
];

const erc20Abi = [
  { name: 'balanceOf', type: 'function', stateMutability: 'view',
    inputs: [{ name: 'o', type: 'address' }], outputs: [{ type: 'uint256' }] },
  { name: 'allowance', type: 'function', stateMutability: 'view',
    inputs: [{ name: 'o', type: 'address' }, { name: 's', type: 'address' }],
    outputs: [{ type: 'uint256' }] },
  { name: 'approve', type: 'function', stateMutability: 'nonpayable',
    inputs: [{ name: 's', type: 'address' }, { name: 'a', type: 'uint256' }],
    outputs: [{ type: 'bool' }] },
];

const policyAbi = [
  { name: 'checkRegistration', type: 'function', stateMutability: 'view',
    inputs: [{ name: 'ns', type: 'string' }, { name: 'name', type: 'string' }],
    outputs: [{ type: 'uint256' }] },
];

const [name] = process.argv.slice(2);
if (!process.env.PRIVATE_KEY || !name) {
  console.error('Usage: PRIVATE_KEY=0x... node register-name.mjs <name>');
  process.exit(1);
}

const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_RPC) });
const client = createPublicClient({ chain: polygon, transport: http(POLYGON_RPC) });

const read = (address, abi, functionName, args = []) =>
  client.readContract({ address, abi, functionName, args });

if (await read(XAYA_ACCOUNTS, accountsAbi, 'exists', ['p', name])) {
  console.error(`p/${name} is already taken.`);
  process.exit(1);
}

const policy = await read(XAYA_ACCOUNTS, accountsAbi, 'policy');
const wchi = await read(XAYA_ACCOUNTS, accountsAbi, 'wchiToken');
const fee = await read(policy, policyAbi, 'checkRegistration', ['p', name]);
console.log(`Registration fee for p/${name}: ${formatUnits(fee, 8)} WCHI`);

const balance = await read(wchi, erc20Abi, 'balanceOf', [account.address]);
if (balance < fee) {
  console.error(`Insufficient WCHI: have ${formatUnits(balance, 8)}, need ${formatUnits(fee, 8)}`);
  process.exit(1);
}

const allowance = await read(wchi, erc20Abi, 'allowance', [account.address, XAYA_ACCOUNTS]);
if (allowance < fee) {
  console.log('Approving WCHI...');
  const approveHash = await wallet.writeContract({
    address: wchi, abi: erc20Abi, functionName: 'approve', args: [XAYA_ACCOUNTS, fee],
  });
  await client.waitForTransactionReceipt({ hash: approveHash });
}

console.log(`Registering p/${name}...`);
const hash = await wallet.writeContract({
  address: XAYA_ACCOUNTS, abi: accountsAbi, functionName: 'register', args: ['p', name],
});
const receipt = await client.waitForTransactionReceipt({ hash });
console.log(`Registered p/${name} in block ${receipt.blockNumber} (status: ${receipt.status})`);

And send-move.mjs:

#!/usr/bin/env node
// Send a Mover move on Polygon via the XayaAccounts contract.
//
// Usage:
//   PRIVATE_KEY=0x... node send-move.mjs <name> <direction> <steps>
//
// Example (move p/myname up by 2):
//   PRIVATE_KEY=0x... node send-move.mjs myname k 2
//
// The wallet behind PRIVATE_KEY must own the Xaya name (an ERC-721 token
// of the XayaAccounts contract) and have a little POL for gas.

import { createWalletClient, createPublicClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { polygon } from 'viem/chains';

const XAYA_ACCOUNTS = '0x8C12253F71091b9582908C8a44F78870Ec6F304F';
const POLYGON_RPC = process.env.POLYGON_RPC ?? 'https://polygon-node.xaya.io';
const MAX_UINT256 = 2n ** 256n - 1n;

const moveAbi = [
  {
    name: 'move',
    type: 'function',
    stateMutability: 'nonpayable',
    inputs: [
      { name: 'ns', type: 'string' },
      { name: 'name', type: 'string' },
      { name: 'mv', type: 'string' },
      { name: 'nonce', type: 'uint256' },
      { name: 'amount', type: 'uint256' },
      { name: 'receiver', type: 'address' },
    ],
    outputs: [{ type: 'uint256' }],
  },
];

const [name, direction, steps] = process.argv.slice(2);
if (!process.env.PRIVATE_KEY || !name || !direction || !steps) {
  console.error('Usage: PRIVATE_KEY=0x... node send-move.mjs <name> <direction> <steps>');
  process.exit(1);
}

// The move envelope: {"g":{"mv":{...}}} tells Xaya this is a move for the
// game with ID "mv" (Mover).  The inner object is the game-specific move.
const moveJson = JSON.stringify({ g: { mv: { d: direction, n: Number(steps) } } });

const account = privateKeyToAccount(process.env.PRIVATE_KEY);
const wallet = createWalletClient({ account, chain: polygon, transport: http(POLYGON_RPC) });
const client = createPublicClient({ chain: polygon, transport: http(POLYGON_RPC) });

console.log(`Sending move for p/${name}: ${moveJson}`);
const hash = await wallet.writeContract({
  address: XAYA_ACCOUNTS,
  abi: moveAbi,
  functionName: 'move',
  // 'p' is the player namespace; MAX_UINT256 skips the nonce check;
  // amount 0 / zero receiver since no WCHI payment is attached.
  args: ['p', name, moveJson, MAX_UINT256, 0n, '0x0000000000000000000000000000000000000000'],
});
console.log('Transaction sent:', hash);

const receipt = await client.waitForTransactionReceipt({ hash });
console.log(`Confirmed in block ${receipt.blockNumber} (status: ${receipt.status})`);

Both scripts default to the public Polygon RPC node run by the Xaya team (https://polygon-node.xaya.io); set POLYGON_RPC to override it.

3. Register a name

Names live in the p (player) namespace and are documented in detail in Names and Moves. Pick a name nobody has taken yet and run:

PRIVATE_KEY=0xYOUR_KEY node register-name.mjs myname

The script prints its progress as it goes:

Registration fee for p/myname: 1 WCHI
Approving WCHI...
Registering p/myname...
Registered p/myname in block <your block number> (status: success)

(The fee line is a real captured output — we verified the 1 WCHI fee live on the policy contract. The remaining steps follow the same verified contract interface; if you already own a name, you can skip registration entirely.)

What the script actually does, step by step — this follows the verified on-chain interface of the XayaAccounts contract:

  1. Existence check. exists('p', name) on XayaAccounts (0x8C12253F71091b9582908C8a44F78870Ec6F304F) — names are unique, so the script bails out early if yours is taken.
  2. Fee query. It asks XayaAccounts for its policy() contract (0x997A8B19d200A453D77c3857E81Af31F680b3663) and calls checkRegistration('p', name) on it. This returns the fee in WCHI base units. Verified live: the fee is a flat 1 WCHI (checked for name lengths 1 through 10).
  3. Approve. Registration fees are pulled from your wallet via ERC-20 allowance, so the script calls WCHI.approve(XayaAccounts, fee) — but only if the existing allowance is too low.
  4. Register. XayaAccounts.register('p', name) takes the WCHI and mints p/myname to your wallet as an ERC-721 token. You can see it like any NFT.

Names are permanent — there is no renewal fee, and the name is yours until you transfer the NFT. The same name works for every Xaya game: p/myname can play Mover today and XayaShips tomorrow.

4. Send your first move

Now move your player two steps "up" (k is up, vim/nethack-style — full table in section 8):

PRIVATE_KEY=0xYOUR_KEY node send-move.mjs myname k 2

You should see something like (real output from the live run, where the name was xsv5bob):

Sending move for p/xsv5bob: {"g":{"mv":{"d":"k","n":2}}}
Transaction sent: 0x21f9ad768d8556d1118d64d49556406dbd7c5bfd9e287c8151a5c19ea4d0ffd3
Confirmed in block 88370257 (status: success)

With Polygon's ~2 second block time, confirmation is nearly instant, and the gas cost is well under $0.01 in POL.

Look at the JSON the script printed — this is the move envelope:

{"g":{"mv":{"d":"k","n":2}}}
  • The outer "g" object holds game moves. Its keys are game IDs; one transaction can target several games at once.
  • "mv" is Mover's game ID, so the value {"d":"k","n":2} is Mover's game-specific move data: direction k (up), 2 steps.
  • This whole string is the mv argument of XayaAccounts.move('p', name, mv, nonce, amount, receiver). The GSP for game mv will receive only the inner {"d":"k","n":2} part, together with your name.

The remaining move() arguments in the script: nonce = MAX_UINT256 skips the contract's optional nonce check, and amount = 0 with a zero receiver address means no WCHI payment rides along with the move. See Names and Moves for the full story.

5. Watch the GSP process it

Your GSP from Part 1 is subscribed to new blocks via XayaX. Within a few seconds of confirmation, the move reaches it. Check the logs (from your ~/mover-polygon/docker directory):

docker compose logs moverd | grep "Processed"

You should see something like:

Processed 1 moves forward, new state has 1 players

That line is your game logic's ProcessForward running: it parsed the move out of the block data, applied Mover's rules, and committed a new game state (plus undo data, in case of a reorg — see What Makes a GSP).

6. Read the state

Now query the GSP's JSON-RPC interface for the current game state:

curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"getcurrentstate","params":[]}' \
  http://127.0.0.1:8602

You should see something like (real output, a few seconds after the move above):

{"id":1,"jsonrpc":"2.0","result":{"blockhash":"5e10e660e23015470501b6e6a41d88290b76509a544590867d39e2d7d176a05b","chain":"polygon","gameid":"mv","gamestate":{"players":{"xsv5bob":{"x":0,"y":2}}},"height":88370263,"state":"up-to-date"}}

Your player exists and is at (0, 2). Note the name in the state has no p/ prefix — the GSP only ever deals with names inside the player namespace.

Why (0, 2)?

You sent one move and the player is already at its destination. This is Mover's per-block movement model. Each block, the game logic does:

  1. Players sending their first move appear at (0, 0).
  2. Movers' direction and steps are set from their move.
  3. Every player with steps left moves 1 step.
  4. steps_left decrements.
  5. Direction clears when steps_left hits 0.

Walk through it for {"d":"k","n":2} sent by a brand-new player, confirmed in block 88370257:

Block What happens Position after Steps left
88370257 (the move's block) Player appears at (0,0); direction set to k, steps to 2; moves 1 step up (0, 1) 1
88370258 (next block) Moves 1 more step up; steps hit 0, direction clears (0, 2) 0

So a player moves 1 step in the move's own block, and a fresh player sending {"d":"k","n":2} ends at (0, 2) two blocks later. Since the direction cleared, the final state shows only {"x":0,"y":2} — no dir/steps fields. If you query while a long move is in flight (try n of 50), you will catch intermediate positions with dir and steps still set, advancing one step per ~2-second Polygon block.

This is also a nice determinism demo: in the verified run, two independently-synced moverd instances returned byte-identical gamestate for this query. Anyone running the same GSP code computes exactly the same world.

7. Keep watching: waitforchange

Polling getcurrentstate in a loop works but is wasteful. The GSP offers a long-poll method, waitforchange: pass the block hash you currently know, and the call blocks until the state moves past it, then returns the new best block hash.

curl -s -X POST -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"waitforchange","params":["5e10e660e23015470501b6e6a41d88290b76509a544590867d39e2d7d176a05b"]}' \
  http://127.0.0.1:8602

The request hangs briefly, then returns the hash of the new best block. On Polygon, with its ~2 second blocks, this typically resolves in about 2–3 seconds (verified). If the hash you pass is stale or empty, the call returns immediately with the current best hash.

The standard client loop is:

loop:
  newHash = waitforchange(lastKnownHash)
  state   = getcurrentstate()
  render state
  lastKnownHash = newHash

The GSP handles concurrent requests fine — in the verified run, a getnullstate issued during a pending waitforchange answered in 7 ms. The full RPC surface (getcurrentstate, getnullstate, getpendingstate, waitforchange, stop) is documented in the GSP RPC Interface reference.

8. Mover move reference

A Mover move is {"d": DIR, "n": STEPS}:

d Direction Vector (dx, dy)
l right (+1, 0)
h left (−1, 0)
k up (0, +1)
j down (0, −1)
u up-right (+1, +1)
n down-right (+1, −1)
y up-left (−1, +1)
b down-left (−1, −1)

(The letters are vim/nethack movement keys. Don't confuse direction n — down-right — with the steps field, which is also named n.)

n (steps) must be a positive integer no larger than 1,000,000. The player walks 1 step per block until the steps run out or a new move overrides direction and steps. The plane is an infinite 2D integer grid — there are no walls.

Try a few more moves and watch the state change:

PRIVATE_KEY=0xYOUR_KEY node send-move.mjs myname u 5    # 5 steps up-right
PRIVATE_KEY=0xYOUR_KEY node send-move.mjs myname h 3    # 3 steps left

What if you send garbage — {"d":"q","n":-7}? Nothing breaks: the transaction confirms on-chain like any other, but every GSP ignores invalid moves (moverd logs an Ignoring invalid move warning). Validity is decided by the game rules in the GSP, not by the chain — a key idea explored in What Makes a GSP.

Next: how the code works

You have now played a fully decentralized game: your identity is an NFT, your moves are Polygon transactions, and the game state was computed by your own node. In Part 3 we open up Mover's source — logic.cpp, moves.cpp, and the protobuf state — and see exactly how ProcessForward, undo data, and the per-block rules from section 6 are implemented.

Clone this wiki locally