-
Notifications
You must be signed in to change notification settings - Fork 10
Tutorial Mover Part 1 Setup
In this first part of the Mover tutorial you'll build and run a complete Game State Processor (GSP) for Mover, the official Xaya example game, against Polygon mainnet. You'll clone the game's source from libxayagame, apply a small patch that teaches it about Polygon, build it into a Docker image, and launch it together with the XayaX bridge using Docker Compose. At the end you'll have a fully synced GSP answering JSON-RPC queries on your machine — ready for Part 2, where you'll register a name and play.
Mover is deliberately tiny: players live on an infinite 2D plane and send moves like {"d":"k","n":2} ("go up 2 steps"). Its game ID is mv. Despite its simplicity, running it exercises the entire Xaya stack:
flowchart LR
subgraph polygon["Polygon PoS mainnet"]
acc["XayaAccounts contract<br/>0x8C12...304F"]
end
subgraph dockernet["Docker network: mover-net"]
xayax["xayax container<br/>(image: xaya/xayax)"]
moverd["moverd container<br/>(built in this tutorial)"]
end
you["Your machine<br/>(curl, scripts, anything JSON-RPC)"]
acc -- "move events via<br/>https://polygon-node.xaya.io" --> xayax
xayax -- "JSON-RPC :8000 + ZMQ :28555" --> moverd
xayax -. "127.0.0.1:8101" .-> you
moverd -- "127.0.0.1:8602" --> you
Data flows one way: chain → XayaX → GSP → your tools. The GSP never writes to the chain — that's what your wallet is for, and we'll get to it in Part 2.
If you haven't read What Makes a GSP and Xaya Architecture yet, they explain the concepts behind everything we do here. This page is hands-on.
-
Docker Engine with the Docker Compose plugin (
docker compose, not the olddocker-compose). - An internet connection. You do not need your own Polygon node — by default we use the public RPC endpoint run by the Xaya team,
https://polygon-node.xaya.io. - About 10 minutes: a few minutes for the image build and roughly two minutes for the initial sync.
No wallet, tokens, or blockchain accounts are needed for this part. Running a GSP is pure read-only observation of the chain.
The Mover sources live in the libxayagame repository. We'll clone it once and copy just the files we need into a fresh project directory, so our patched copy stays cleanly separated from the upstream sources.
cd ~
git clone https://github.com/xaya/libxayagame
mkdir -p ~/mover-polygon/mover/proto ~/mover-polygon/dockerCopy the Mover game files:
cp ~/libxayagame/mover/logic.cpp \
~/libxayagame/mover/logic.hpp \
~/libxayagame/mover/moves.cpp \
~/libxayagame/mover/moves.hpp \
~/libxayagame/mover/pending.cpp \
~/libxayagame/mover/pending.hpp \
~/libxayagame/mover/main.cpp \
~/mover-polygon/mover/
cp ~/libxayagame/mover/proto/mover.proto ~/mover-polygon/mover/proto/Your project should now look like this (the two docker/ files are created in steps 4 and 5):
~/mover-polygon/
├── docker/
│ ├── Dockerfile ← created in step 4
│ └── docker-compose.yml ← created in step 5
└── mover/
├── logic.cpp ← we'll patch this one
├── logic.hpp
├── main.cpp
├── moves.cpp
├── moves.hpp
├── pending.cpp
├── pending.hpp
└── proto/
└── mover.proto
What each file does (covered in depth in Part 3):
| File | Role |
|---|---|
logic.cpp/hpp |
The GameLogic subclass: initial state, forward/backward processing, JSON output |
moves.cpp/hpp |
Parsing and validating the {"d":...,"n":...} move format |
pending.cpp/hpp |
Pending-move (mempool) tracking — unused on Polygon, but needed to link |
main.cpp |
Wires everything into DefaultMain and produces the moverd daemon |
proto/mover.proto |
Protobuf definitions for the game state and undo data |
Two reasons you can't just run the stock binaries:
-
The stock Mover doesn't know about Polygon.
GetInitialStateInternalinlogic.cpphas cases for the original Xaya Core chains (MAIN,TEST,REGTEST) and then hitsLOG (FATAL) << "Unexpected chain". Connect an unpatched moverd to a Polygon XayaX and it dies on startup. -
The prebuilt
moverdin thexaya/libxayagameDocker image is unusable. It was linked againstlibmover.so, which isn't installed in the image, so it can't even start. You must build your own.
The fix is a single new case. Open ~/mover-polygon/mover/logic.cpp, find the switch (chain) block in GetInitialStateInternal, and add this after the Chain::REGTEST case (and before default:):
case Chain::POLYGON:
/* The game state starts at a recent Polygon block. Moves sent
before this height are ignored. The hash is left empty so that
any block at this height is accepted (we trust the connected
XayaX instance rather than pinning an exact block hash). */
height = 88365000;
hashHex = "";
break;After the edit, the switch should read:
case Chain::REGTEST:
height = 0;
hashHex
= "6f750b36d22f1dc3d0a6e483af45301022646dfc3b3ba2187865f5a7d6d83ab1";
break;
case Chain::POLYGON:
/* The game state starts at a recent Polygon block. Moves sent
before this height are ignored. The hash is left empty so that
any block at this height is accepted (we trust the connected
XayaX instance rather than pinning an exact block hash). */
height = 88365000;
hashHex = "";
break;
default:
LOG (FATAL) << "Unexpected chain: " << ChainToString (chain);Chain::POLYGON is already defined in the installed xayagame/gamelogic.hpp, so no other changes are needed.
Two design decisions in this hunk deserve explanation:
-
The start height defines your game's genesis. Moves sent before this height are invisible to the game — the GSP simply starts processing at this block. Pick a recent Polygon block from polygonscan.com so your GSP doesn't waste time scanning years of history. We used
88365000, which was a few thousand blocks behind the tip when this tutorial was verified. You can keep that value to reproduce the outputs below exactly, or pick a newer block (your heights and genesis hash will then differ). -
The empty-hash trick. The original chains pin an exact block hash for the genesis block, so a GSP refuses to sync against a chain that doesn't match. Leaving
hashHexempty tells libxayagame to accept whatever block the connected XayaX reports at that height — it fetches the hash itself and logs it. This is convenient for a tutorial (any honest XayaX works); a production game would typically pin the hash once its genesis block is final.
Create ~/mover-polygon/docker/Dockerfile with exactly this content:
# Build the Mover game daemon (moverd) for Polygon.
#
# Uses xaya/libxayagame as the build base, which has all Xaya libraries
# (libxayagame, libxayautil, ...) and their dependencies pre-installed.
#
# XayaX (the Polygon bridge) reports a lower version number than a native
# Xaya Core node, so we patch the MinXayaVersion check in defaultmain.hpp
# before building.
FROM xaya/libxayagame AS build
RUN apt update && apt -y install \
build-essential \
pkg-config
# Patch the version check to accept XayaX's reported version.
RUN sed -i 's/unsigned MinXayaVersion = 1010200/unsigned MinXayaVersion = 1000000/' \
/usr/local/include/xayagame/defaultmain.hpp
WORKDIR /usr/src/mover-polygon
COPY mover/ mover/
# Generate the protobuf C++ files for the game state / undo data.
RUN protoc -Imover --cpp_out=mover mover/proto/mover.proto
# main.cpp includes config.h (normally generated by the build system).
RUN echo '#define PACKAGE_VERSION "polygon-tutorial"' > config.h
# Compile moverd against the installed libraries.
RUN set -e && \
CXXFLAGS="-std=c++14 -I. -Imover \
$(pkg-config --cflags jsoncpp libglog protobuf sqlite3)" && \
g++ $CXXFLAGS -c mover/proto/mover.pb.cc -o mover.pb.o && \
g++ $CXXFLAGS -c mover/moves.cpp -o moves.o && \
g++ $CXXFLAGS -c mover/logic.cpp -o logic.o && \
g++ $CXXFLAGS -c mover/pending.cpp -o pending.o && \
g++ $CXXFLAGS -c mover/main.cpp -o main.o && \
g++ -o moverd main.o logic.o moves.o pending.o mover.pb.o \
-L/usr/local/lib -lxayagame -lxayautil \
$(pkg-config --libs jsoncpp libglog gflags protobuf sqlite3 \
libcurl libzmq libmicrohttpd openssl zlib lmdb \
libjsonrpccpp-client libjsonrpccpp-server libjsonrpccpp-common)
# Collect the binary and its shared library dependencies into /jail
# (cpld is a helper script provided by the xaya/libxayagame image).
WORKDIR /jail
RUN mkdir -p bin lib64 && \
cp /usr/src/mover-polygon/moverd bin/ && \
cpld bin/moverd lib64
# Minimal runtime image.
FROM debian:13-slim
COPY --from=build /jail /usr/local/
ENV LD_LIBRARY_PATH="/usr/local/lib64"
VOLUME ["/xayagame", "/log"]
ENV GLOG_log_dir="/log"
RUN mkdir -p /log /xayagame
EXPOSE 8600
ENTRYPOINT ["/usr/local/bin/moverd"]
CMD ["--xaya_rpc_url=http://xayax:8000", \
"--xaya_rpc_protocol=2", \
"--game_rpc_port=8600", \
"--game_rpc_listen_locally=false", \
"--storage_type=sqlite", \
"--datadir=/xayagame", \
"--enable_pruning=1000", \
"--pending_moves=false"]Base image. xaya/libxayagame ships every library a GSP needs — libxayagame, libxayautil, jsoncpp, glog, protobuf, jsonrpccpp, ZMQ, and the rest — already compiled and installed under /usr/local. We only add build-essential and pkg-config on top to get a compiler.
The MinXayaVersion patch. libxayagame's DefaultMain checks the version of the connected Xaya daemon and refuses anything older than 1010200. XayaX reports version 1000000, so an unpatched build would refuse to talk to the bridge. The sed line lowers the minimum in the installed header before we compile — this is a header-level constant, so patching the header is all it takes.
protoc. Mover stores its game state and undo data as protobuf messages. protoc -Imover --cpp_out=mover mover/proto/mover.proto generates mover.pb.cc/mover.pb.h next to the proto file, which we then compile like any other source.
The config.h stub. main.cpp includes config.h, which the upstream autotools build generates. Since we're compiling by hand, a one-line stub defining PACKAGE_VERSION is enough.
Compile and link. Each .cpp is compiled with -std=c++14 and the include flags pkg-config reports for jsoncpp, glog, protobuf and sqlite3. The link step pulls in -lxayagame -lxayautil plus the full dependency list — libxayagame's DefaultMain touches curl, ZMQ, microhttpd, OpenSSL, zlib, LMDB and all three jsonrpccpp libraries, so all of them must be on the link line.
cpld and the slim runtime. The build image is large; we don't want to ship it. cpld is a helper script provided by the base image that walks the binary's shared-library closure and copies every .so it needs into a folder. We collect moverd plus its libraries into /jail and copy just that into a fresh debian:13-slim stage, pointing LD_LIBRARY_PATH at the copied libraries. The result is a minimal image with exactly one binary in it.
Runtime defaults. The CMD flags are the same ones the compose file passes explicitly — see the next section for what each one means. /xayagame (game database) and /log (glog files) are declared as volumes so state survives container recreation.
Create ~/mover-polygon/docker/docker-compose.yml with exactly this content:
name: ${COMPOSE_PROJECT_NAME:-mover}
# Mover on Polygon: XayaX bridge + moverd GSP.
#
# Run with the bundled XayaX bridge:
# docker compose --profile xayax up -d --build
#
# If you already run a XayaX instance elsewhere, omit the profile and set
# XAYA_RPC_URL to its endpoint. Note that the ZMQ addresses advertised by
# XayaX (getzmqnotifications) must be reachable from the moverd container.
services:
xayax:
image: xaya/xayax
profiles:
- xayax
restart: unless-stopped
networks:
- mover-net
ports:
- "127.0.0.1:${XAYAX_PORT:-8101}:8000"
volumes:
- xayax-data:/xayax
command:
- "eth"
- "--eth_rpc_url=${ETH_RPC_URL:-https://polygon-node.xaya.io}"
- "--accounts_contract=${ACCOUNTS_CONTRACT:-0x8C12253F71091b9582908C8a44F78870Ec6F304F}"
- "--port=8000"
- "--listen_locally=false"
- "--zmq_address=tcp://xayax:28555"
- "--logtostderr"
moverd:
build:
context: ..
dockerfile: docker/Dockerfile
restart: unless-stopped
networks:
- mover-net
ports:
- "127.0.0.1:${MOVERD_PORT:-8602}:8600"
volumes:
- moverd-data:/xayagame
- moverd-logs:/log
environment:
- GLOG_alsologtostderr=1
command:
- "--xaya_rpc_url=${XAYA_RPC_URL:-http://xayax:8000}"
- "--xaya_rpc_protocol=2"
- "--game_rpc_port=8600"
- "--game_rpc_listen_locally=false"
- "--storage_type=sqlite"
- "--datadir=/xayagame"
- "--enable_pruning=1000"
- "--pending_moves=false"
networks:
mover-net:
ipam:
config:
# Pinned to avoid Docker auto-assigning 192.168.0.0/20, which
# shadows the host's 192.168.x LAN routes.
- subnet: 172.16.0.0/24
volumes:
xayax-data: {}
moverd-data: {}
moverd-logs: {}The xayax profile. The bridge service sits behind a Compose profile named xayax. Run with --profile xayax to get a bundled bridge, or omit the profile and set XAYA_RPC_URL to point moverd at a XayaX you already run (for example one shared by several GSPs — see Running XayaX). One caveat when using an external bridge: XayaX advertises its ZMQ address via getzmqnotifications, and that address must be reachable from the moverd container.
Ports. Both RPC ports are published on 127.0.0.1 only, so nothing is exposed to your network:
| Host port | Container | What it serves |
|---|---|---|
127.0.0.1:8101 |
xayax:8000 | XayaX's Xaya-Core-style JSON-RPC (override with XAYAX_PORT) |
127.0.0.1:8602 |
moverd:8600 | The GSP's own JSON-RPC — the GSP RPC interface (override with MOVERD_PORT) |
Volumes. xayax-data keeps the bridge's database, moverd-data keeps the GSP's SQLite state, and moverd-logs keeps glog files. With these in place, docker compose down && up resumes from where it left off instead of resyncing.
The pinned subnet. Without IPAM config, Docker may auto-assign 192.168.0.0/20 to a new network, which silently shadows routes to a real 192.168.x.x LAN — a classic "why can't my container reach the internet / why did my SSH session die" footgun. Pinning 172.16.0.0/24 avoids it.
XayaX flags. eth selects the EVM connector; --eth_rpc_url is the Polygon node it follows (the public https://polygon-node.xaya.io by default; substitute your own via ETH_RPC_URL if you have one); --accounts_contract is the XayaAccounts contract on Polygon, 0x8C12253F71091b9582908C8a44F78870Ec6F304F (see Names and Moves); --listen_locally=false makes it bind 0.0.0.0 inside the container so moverd and the published port can reach it. The crucial one is --zmq_address=tcp://xayax:28555: this is the address XayaX advertises to subscribers, so it must be the Docker service name — localhost would make moverd try to connect to itself.
moverd flags, one by one:
| Flag | Why |
|---|---|
--xaya_rpc_url=http://xayax:8000 |
The XayaX endpoint, via the Docker service name |
--xaya_rpc_protocol=2 |
JSON-RPC 2.0 — required when talking to XayaX |
--game_rpc_port=8600 |
Port for the GSP's own JSON-RPC server |
--game_rpc_listen_locally=false |
Bind 0.0.0.0 inside the container, so the published port works |
--storage_type=sqlite |
Persistent state on disk (memory and lmdb are the other options) |
--datadir=/xayagame |
Data directory; the GSP creates per-game/per-chain subdirs, here /xayagame/mv/polygon
|
--enable_pruning=1000 |
Keep undo data only for the last 1000 blocks (enough for any realistic reorg) |
--pending_moves=false |
XayaX doesn't provide a pending-moves feed, so don't track mempool state |
(Setting --pending_moves=true is harmless: moverd logs Not subscribing to pending moves because XayaX advertises no pending ZMQ endpoint, and pending state simply stays empty.)
GLOG_alsologtostderr=1 mirrors the glog output to stderr so docker logs shows everything.
From the docker/ directory:
cd ~/mover-polygon/docker
docker compose --profile xayax up -d --buildThe first run builds the moverd image — a few minutes on a normal machine, dominated by the C++ compile — then starts both containers on the mover-net network. Subsequent runs reuse the cached image.
Follow the GSP's log:
docker compose --profile xayax logs -f moverdYou should see something like (key lines, in order):
Connected to RPC daemon with chain polygon
Connected to Xaya Core version 1000000
Detected ZMQ blocks endpoint: tcp://xayax:28555
Got genesis height from game: 88365000
Game did not specify genesis hash, retrieved 701ca380fb3ec9bf7e0abd3d4f89895dae229e1138c2fd471245b486568dba10
Retrieving 0 detach and 128 attach steps with reqtoken = request_461, ...
Reading those lines back against what we built:
-
chain polygon/version 1000000— moverd is talking to XayaX, and theMinXayaVersionpatch is doing its job (1000000would be rejected by an unpatched build). -
Detected ZMQ blocks endpoint: tcp://xayax:28555— the GSP auto-discovered the block-notification feed from the address XayaX advertises; this is why that flag must use the service name. -
Got genesis height from game: 88365000— our patchedChain::POLYGONcase. -
Game did not specify genesis hash, retrieved 701ca380...— the empty-hash trick: libxayagame fetched the hash for block 88365000 from XayaX and pinned it itself. (If you chose your own start height, you'll see a different hash here.) - The
Retrieving ... attach stepslines repeat as libxayagame catches up in batches of blocks. In our verified run, catching up the ~5300 blocks between genesis 88365000 and the chain tip took about 2 minutes.
A note on the bridge side: a fresh XayaX starts near the current Polygon tip and is typically serving RPC within ~2 minutes, then backfills older blocks on demand when a GSP asks for them. That's exactly what happens here — our bridge started at tip ~88370000 and served the GSP's genesis block 88365000 on request. You can watch it with docker compose --profile xayax logs -f xayax.
You can also query the bridge directly to confirm it's healthy:
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"getblockchaininfo","params":[]}' \
http://127.0.0.1:8101You should see something like:
{"id":1,"jsonrpc":"2.0","result":{"bestblockhash":"...","blocks":88370214,"chain":"polygon"}}with blocks near the current Polygon tip (Polygon produces a block roughly every 2 seconds, so the number climbs fast).
The cheapest sync check is getnullstate — the standard state envelope without the (potentially large) game state attached:
curl -s -X POST -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"getnullstate","params":[]}' \
http://127.0.0.1:8602You should see something like:
{"id":1,"jsonrpc":"2.0","result":{"blockhash":"6a4c517105b5b97a7052848c812f62c5ed6dca59950877caa27b285e5d309e09","chain":"polygon","gameid":"mv","height":88370225,"state":"up-to-date"}}The fields to check:
-
"state":"up-to-date"— the GSP has processed every block XayaX knows about. While syncing you'll see"catching-up"instead; just wait and query again. -
"gameid":"mv","chain":"polygon"— you're talking to the right daemon on the right chain. -
height/blockhash— the Polygon block the current game state corresponds to; it advances every couple of seconds.
That's it — you're running a full Game State Processor for Mover on Polygon mainnet. If you ask getcurrentstate, the game state starts out empty ({"players":{}}) until someone sends a Mover move after your chosen genesis block — if you kept our start height, you may already see players that other readers (or we, while verifying Part 2) have moved since. The full set of RPC methods your GSP now serves is documented in the GSP RPC interface reference.
A few commands you'll want later:
# Stop everything (state is kept in the named volumes):
docker compose --profile xayax down
# Start again (no rebuild needed; resumes from stored state):
docker compose --profile xayax up -d
# Rebuild after changing logic.cpp (e.g. a new start height):
docker compose --profile xayax up -d --buildNote that the game state is derived deterministically, so you can always throw the volumes away (docker compose --profile xayax down -v) and resync from scratch — you'll get bit-identical state back.
Your GSP is watching the chain, but the board is empty. In Part 2 you'll register a Xaya name (an ERC-721 on Polygon), send a real move through the XayaAccounts contract, and watch your player appear in the game state — for well under a cent in gas. Then Part 3 walks through the code you just compiled.
Xaya developer documentation — verified on Polygon mainnet. Questions? Xaya Development Discord