-
Notifications
You must be signed in to change notification settings - Fork 10
What Makes a GSP
This page is the conceptual core of these docs. It explains what a Game State Processor (GSP) actually is, the four functions you must implement to turn libxayagame into a complete game engine, why undo data and determinism matter, and everything the library handles for you so you don't have to. Once you understand this page, the Mover tutorial will read like a worked example rather than magic.
A GSP is a daemon: libxayagame plus your game logic. It reads the ordered sequence of moves from the blockchain (via XayaX) and deterministically derives the authoritative game state from them. It never writes to the chain — data flows one way:
flowchart LR
A["Polygon PoS<br/>XayaAccounts moves"] --> B[XayaX bridge]
B -- "JSON-RPC + ZMQ" --> C["GSP<br/>libxayagame + your GameLogic"]
C -- "JSON-RPC" --> D[Any client or tool]
Players send moves as ordinary transactions (Names and Moves). Your GSP watches those moves arrive in blocks and applies your game's rules to them, block by block. Because the chain gives everyone the exact same ordered move log, and your rules are deterministic code, everyone who runs your GSP computes bit-identical state. There is no game server to trust — the GSP is the game, and anyone can run one. (We verified this directly: two independently synced mover GSPs returned byte-identical gamestate JSON.)
If you haven't read Blockchain Gaming Basics and Xaya Architecture yet, they set up the why; this page is the how.
To build a GSP, you subclass xaya::GameLogic (from xayagame/gamelogic.hpp) and implement three callbacks, plus a JSON converter. The state and undo types are deliberately simple — both are just byte strings:
using GameStateData = std::string;
using UndoData = std::string;You can put anything in them: a serialized protobuf (what mover does), JSON, or — for big games — just a handle like a block hash while the real state lives in an SQLite database (see Storage backends below).
These are the three callbacks, with their real signatures from the header:
virtual GameStateData GetInitialStateInternal (unsigned& height,
std::string& hashHex) = 0;
virtual GameStateData ProcessForwardInternal (const GameStateData& oldState,
const Json::Value& blockData,
UndoData& undoData) = 0;
virtual GameStateData ProcessBackwardsInternal (const GameStateData& newState,
const Json::Value& blockData,
const UndoData& undoData) = 0;(The public GetInitialState / ProcessForward / ProcessBackwards methods are thin wrappers that libxayagame calls; they set up a Context for you — giving access to the chain, the game ID and a seeded random-number generator — and then delegate to your ...Internal implementations.)
Returns your game's genesis state, and tells libxayagame at which block height the game starts. Moves before that height simply don't exist as far as your game is concerned. You typically switch on the chain:
const Chain chain = GetContext ().GetChain ();
switch (chain)
{
case Chain::POLYGON:
height = 88365000;
hashHex = ""; // accept any block at this height
break;
...
}If hashHex is left empty, libxayagame fetches the hash of that block from the connected XayaX instance instead of pinning an exact one. You should see something like this in the GSP log:
Got genesis height from game: 88365000
Game did not specify genesis hash, retrieved 701ca380fb3ec9bf7e0abd3d4f89895dae229e1138c2fd471245b486568dba10
The xaya::Chain enum covers Xaya Core chains (MAIN, TEST, REGTEST) and EVM chains (POLYGON, MUMBAI, GANACHE) — switching chains means adding one case here and nothing else. See Other Chains.
This is the heart of your game. Given the previous state and one block's worth of data, compute the new state. blockData contains the block metadata plus blockData["moves"], an array where each entry has:
-
"name"— the player's Xaya name, without thep/prefix (e.g."xsv5bob"), -
"move"— the game-specific move JSON, i.e. the inner value your game ID had in the move envelope. For mover, a player sending{"g":{"mv":{"d":"k","n":2}}}on-chain shows up here as{"d":"k","n":2}.
Mover's implementation loops over the moves, validates each one, updates player intentions, then advances every moving player one step. When it runs, you should see something like:
Processed 1 moves forward, new state has 1 players
ProcessForwardInternal has a second job: it must also fill in undoData — enough information to reverse this exact block later. More on that below.
Given the new state, the same block data, and the undo data your forward function produced, reconstruct the old state exactly. This is what makes reorgs cheap (next section).
virtual Json::Value GameStateToJson (const GameStateData& state);Your internal state is whatever encoding you like; GameStateToJson converts it to JSON only when a client asks via the GSP's JSON-RPC interface. The default implementation returns the raw state string, which is fine if your state already is JSON; mover decodes its protobuf into {"players": {...}}. This is the gamestate field clients get back:
{"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"}}That's the entire contract. Genesis, forward, backward, to-JSON — four functions, and libxayagame turns them into a full daemon.
Blockchains reorganize. Polygon occasionally replaces the last block or two with a competing branch, and your GSP's state — which was computed from the now-orphaned blocks — must follow the new branch. The naive fix would be to throw the state away and re-sync from genesis on every reorg. That obviously doesn't scale.
Instead, libxayagame asks you for an inverse function. For every block it attaches with ProcessForward, it stores the UndoData you produced. When a reorg happens, it detaches the orphaned blocks one by one with ProcessBackwards, then attaches the new branch with ProcessForward:
sequenceDiagram
participant X as XayaX
participant L as libxayagame
participant G as Your GameLogic
X->>L: block N+2' (different parent!)
Note over L: reorg detected
L->>G: ProcessBackwards(state, block N+1, undo N+1)
G-->>L: state at block N
L->>G: ProcessForward(state, block N+1')
G-->>L: state at N+1' + undo N+1'
L->>G: ProcessForward(state, block N+2')
G-->>L: state at N+2' + undo N+2'
Why can't ProcessBackwards work from the block data alone? Because forward processing destroys information. In mover, when a player's steps_left reaches 0, their direction is cleared — going backwards, you couldn't know what it was. So mover's undo data records, per player touched in the block, the previous direction and steps, and whether the player was newly created. That's a minimal per-block diff, and it's the "proper" way.
There is also a cheap hack that is officially supported: make the undo data the entire old state. Then ProcessBackwards is trivial — just return the undo data. libxayagame ships a helper subclass for exactly this:
class CachingGame : public GameLogic
{
// implement only this instead of forward/backwards:
virtual GameStateData UpdateState (const GameStateData& oldState,
const Json::Value& blockData) = 0;
};For prototypes and games with small state, CachingGame (ideally combined with pruning, below) means you can skip undo logic entirely. For anything with real state size, write proper diffs — the code walkthrough dissects mover's version line by line.
This is the entire point of the library: your code is just rules; everything operational is handled. When you wire your GameLogic subclass up with DefaultMain (config, "yourgameid", rules), you get, with zero additional code:
-
Chain connection management — the JSON-RPC client to XayaX, including reconnecting when the connection drops.
-
ZMQ block subscription — auto-detected from the connected node (
Detected ZMQ blocks endpoint: tcp://xayax:28555), so your game is pushed every new block within seconds. -
Initial sync with batching — catching up from genesis is done in batched requests, not block-by-block RPC. A real log line from syncing mover on Polygon:
Retrieving 0 detach and 128 attach steps with reqtoken = request_461, ...Catching up ~5,300 Polygon blocks took about 2 minutes.
-
Reorg detection and rewind — you write
ProcessBackwards; the library decides when, and for which blocks, to call it. -
Storage with transactions — state and undo data are persisted atomically per block, in your choice of backend, so a crash mid-block never corrupts state.
-
Pruning of old undo data (see below).
-
A JSON-RPC server exposing
getcurrentstate,getnullstate,waitforchangeand friends to your clients — the complete GSP RPC interface. -
The main loop tying it all together, plus standard flags (
--storage_type,--datadir,--game_rpc_port,--enable_pruning, ...).
Mover's main.cpp is essentially flag parsing plus one DefaultMain call. You write a turn-based state machine; you get a production blockchain daemon.
Everything above rests on one property: the game state must be a pure function of the on-chain move sequence. Two GSPs given the same blocks must produce identical bytes, forever. Concretely, inside your callbacks:
-
No wall-clock time. Block heights and block timestamps (from
blockData) are your only clocks — they're on-chain and identical for everyone. - No external I/O. No HTTP calls, no reading files, no databases other than your own game-state storage. Anything outside the chain can differ between nodes.
-
No unseeded randomness.
rand()or hardware entropy would diverge instantly. If your game needs randomness, use the seeded generator libxayagame hands you —GetContext ().GetRandom ()is deterministically seeded per block from on-chain data, so every node draws the same "random" numbers. - No floating-point ambiguity. Float results can differ across compilers, platforms, and optimization levels. Use integers (mover's whole world is an integer plane) or fixed-point arithmetic.
- No iteration-order surprises. If the order in which you process entities affects the outcome, iterate in a defined order (e.g. sorted by name) — never in the unspecified order of a hash map. (Mover sidesteps this: each player's update is independent of the others.)
Break any of these and your GSP will eventually "fork" against other instances of itself — and against your own re-syncs.
The chain doesn't know your game's rules. Anyone can put any JSON under your game ID in a move transaction — the XayaAccounts contract happily records it. Validation happens entirely in the GSP: your ProcessForward parses each move, and if it doesn't conform to your rules, you skip it and carry on. Mover:
if (!ParseMove (obj, dir, steps))
{
LOG (WARNING) << "Ignoring invalid move:\n" << obj;
continue;
}There is no "rejected transaction" — the move sits on-chain forever, and every honest GSP ignores it identically. This is the security model, and it's stronger than it first sounds:
- A cheater can't send a "teleport" move and have it count, because every GSP applies the same validation. To cheat, they'd have to change the rules in every GSP run by every player and observer simultaneously — at which point they're just playing a different game by themselves.
- A cheater also can't forge moves for someone else's name: moves are transactions signed by the name's owner, enforced by the contract (Names and Moves).
- There's no server to hack and no admin who can quietly alter outcomes.
The flip side is that your validation code is the law. If ParseMove accepts something it shouldn't, that's not a cheat, it's a rule of your game now — every GSP will faithfully enforce the bug. Write your move parser defensively and test it.
Every game has a short string ID. Moves are wrapped in an envelope keyed by game ID:
{"g":{"mv":{"d":"k","n":2}}}Mover's ID is mv. The GSP for mv receives only the inner {"d":"k","n":2}; moves for other game IDs in the same transaction (one transaction can target multiple games) are invisible to it. You pass your game ID to DefaultMain, and libxayagame filters the chain for you. Pick something short and unique among Xaya games — the live battleships game, XayaShips, uses xs-prefixed IDs, for instance.
libxayagame's DefaultMain ships three storage backends, selected with --storage_type:
| Backend | Use when |
|---|---|
memory |
Testing and development. Nothing persists; full re-sync on every restart. |
sqlite |
The sensible default for real deployments. Single file, transactional. |
lmdb |
Alternative persistent key-value backend; useful if you prefer LMDB's performance profile. |
Data lands under --datadir, in a subdirectory per game ID and chain — for mover on Polygon: <datadir>/mv/polygon/storage.sqlite.
These backends store your opaque GameStateData and per-block UndoData blobs. That's perfect for compact states, but a large game world serialized into one blob per block would be painful. For that, libxayagame offers xaya::SQLiteGame: instead of GameLogic, you subclass SQLiteGame and your state is an SQLite database — your forward function mutates tables, and the library handles per-block transactions and reorg rollback at the database level. If your game has thousands of entities, start there. (Mover is small, so plain GameLogic with a protobuf blob is all it needs.)
Undo data accumulates one entry per block — at Polygon's ~2-second block time, that's ~43,000 entries a day, almost all of which you'll never need: reorgs only ever touch the last few blocks. The flag
--enable_pruning=1000
tells libxayagame to keep undo data only for the most recent 1,000 blocks and delete the rest. If a reorg deeper than that ever happened (it won't, in practice), the GSP would simply re-sync from genesis — remember, the chain holds the full move history, so the state is always recoverable. Pruning is what makes the CachingGame "store the whole state as undo data" hack viable too: 1,000 copies of a small state is nothing.
A complete GSP is:
flowchart TB
subgraph daemon["GSP daemon"]
direction TB
LX["libxayagame<br/>sync · reorgs · storage · pruning · RPC server"]
GL["Your GameLogic subclass<br/>GetInitialState · ProcessForward · ProcessBackwards · GameStateToJson"]
LX <--> GL
end
XX[XayaX] -- "blocks and moves" --> LX
LX -- "getcurrentstate, waitforchange" --> CL[Clients]
You define the genesis, the rules, the inverse of the rules, and a JSON view. The library does literally everything else.
And this isn't theoretical: the Mover tutorial builds and runs exactly such a GSP against Polygon mainnet, and the entire game logic — genesis, forward processing, backward processing, undo protobuf, JSON output — is about 400 lines of game code across logic.cpp and moves.cpp. Start there, then come back to the code walkthrough with this page fresh in mind.
Quick reference for the terms used here: Glossary.
Xaya developer documentation — verified on Polygon mainnet. Questions? Xaya Development Discord