Skip to content

Tutorial Mover Part 3 Code Walkthrough

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

Tutorial: Mover, Part 3 — Code Walkthrough

In Part 1 you built and synced the mover GSP for Polygon, and in Part 2 you played it with real on-chain moves. In this final part you read the actual code: every file of the mover daemon, line by line where it matters. By the end you will understand how a complete GSP is put together — the protobuf state, the defensive move parser, the forward/backward state transitions with undo data, the JSON view for RPC clients, pending-move tracking, and the main() that wires it all into libxayagame. This is the blueprint you'll adapt when you build your own game.

If you haven't read What Makes a GSP yet, do that first — this page assumes you know what ProcessForward, undo data, and reorgs are conceptually. Here we see them in real C++.

1. File map

The mover source you copied in Part 1 (from the mover/ directory of libxayagame) looks like this in ~/mover-polygon/mover:

mover/
├── proto/
│   └── mover.proto      # Game state, player state, and undo data as protobuf messages
├── moves.hpp / .cpp     # Move parsing and direction helpers (pure functions)
├── logic.hpp / .cpp     # The game rules: MoverLogic, a xaya::GameLogic subclass
├── pending.hpp / .cpp   # Optional pending-move tracking (idle on Polygon/XayaX)
└── main.cpp             # Flags, configuration, DefaultMain — the daemon entry point

That's the whole game. Roughly 700 lines of C++ (sources and headers together) plus a 90-line proto file. Everything else — chain connection, ZMQ subscriptions, reorg handling, storage, pruning, the JSON-RPC server — lives in libxayagame and is configured, not written, in main.cpp.

How the pieces depend on each other:

graph TD
    P["proto/mover.proto<br/>(generated: mover.pb.h/.cc)"] --> M["moves.cpp<br/>parse + direction math"]
    P --> L["logic.cpp<br/>MoverLogic game rules"]
    P --> PE["pending.cpp<br/>PendingMoves"]
    M --> L
    M --> PE
    L --> MAIN["main.cpp"]
    PE --> MAIN
    MAIN --> DM["xaya::DefaultMain<br/>(libxayagame)"]
Loading

The upstream libxayagame repository also contains mover/logic_tests.cpp, mover/moves_tests.cpp, mover/pending_tests.cpp, and a mover/gametest/ directory with Python integration tests; we'll touch on those in section 8. The stripped-down copy from Part 1 only contains the files needed to build the daemon.

2. proto/mover.proto — the state, in binary

In libxayagame, xaya::GameStateData and xaya::UndoData are opaque byte strings — the library stores them, hashes them, prunes them, but never looks inside. You decide the encoding. Mover uses Protocol Buffers (proto2 syntax), which gives you:

  • Compact binary encoding — a player at the origin is a handful of bytes, not a JSON string. Storage and undo data stay small.
  • A schema — the state's shape is declared once and the C++ accessors are generated, so you can't typo a field name.
  • Pure, reproducible round-tripsParseFromString → game logic → SerializeToString involves no floating point, no locale, no wall clock. The state your GSP derives is a pure function of the move sequence, which is exactly the determinism a GSP needs (in Part 2 you verified two independently synced instances agree byte-for-byte on the state JSON).

The game state itself is tiny — a map from player names to positions:

/** The state of a particular player in Mover.  */
message PlayerState
{
  /** The current x coordinate.  */
  optional sint32 x = 1;
  /** The current y coordinate.  */
  optional sint32 y = 2;

  /** The direction of movement.  */
  optional Direction dir = 3;
  /** The remaining number of movement steps left.  */
  optional uint32 steps_left = 4;
}

/** The full game state.  */
message GameState
{
  /** All players on the map and their current state.  */
  map<string, PlayerState> players = 1;
}

(proto/mover.proto)

Two details worth noticing:

  • Coordinates are sint32, protobuf's zigzag-encoded signed integer — efficient for the negative coordinates of an infinite plane centered on (0, 0).
  • Direction is an enum with NONE = 0 for "not moving" plus the eight compass directions (RIGHT, LEFT, UP, DOWN, and the four diagonals).

The proto file also defines the undo data — the per-block diff that makes reorgs cheap:

/** The undo data for a single player.  */
message PlayerUndo
{
  /**
   * Set to true if the player was not present previously, i.e. if it was
   * first moved and created on the map for this block.
   */
  optional bool is_new = 1;

  /**
   * Previous direction of the player, if it was changed explicitly.
   */
  optional Direction previous_dir = 2;

  /**
   * Previous steps left if the number was changed explicitly by a move.
   */
  optional uint32 previous_steps_left = 3;

  /**
   * Previous direction of the player if it counted down to zero and was
   * changed to NONE in this block.
   */
  optional Direction finished_dir = 4;
}

/** The full undo data for a block.  */
message UndoData
{
  /** Undo data for each player that needs one.  */
  map<string, PlayerUndo> players = 1;
}

(proto/mover.proto)

Notice what is not in the undo data: positions. Movement is reversible from the state itself (if you know the direction, you can step backwards), so only the irreversible changes are recorded — a player appearing for the first time, a direction/steps overwritten by a new move, a direction cleared because the counter hit zero. This is the "minimal diff" approach to undo data described in What Makes a GSP. The lazy alternative — stuffing the entire previous state into the undo blob — also works and is fine for prototypes, but grows linearly with state size.

Because this is proto2, every field carries explicit presence: the generated code has has_previous_dir(), has_finished_dir(), and so on. ProcessBackwardsInternal relies on this to distinguish "the move set a new direction" from "nothing changed".

In Part 1 the Dockerfile generated the C++ code for these messages with:

protoc -Imover --cpp_out=mover mover/proto/mover.proto

3. moves.cpp — parse hostile input first

Here is the single most important security lesson in GSP development: anyone who owns any Xaya name can send any JSON they like as a move for your game. There is no server-side validation before the move reaches your code — the chain just records bytes (see Names and Moves). Your parser is the gatekeeper, and an exploitable parser is an exploitable game.

Mover's ParseMove is short and ruthless:

bool
ParseMove (const Json::Value& obj, proto::Direction& dir, unsigned& steps)
{
  if (!obj.isObject ())
    return false;
  if (obj.size () != 2)
    return false;

  if (!obj.isMember ("d") || !obj.isMember ("n"))
    return false;

  const Json::Value& d = obj["d"];
  const Json::Value& n = obj["n"];
  if (!d.isString () || !n.isUInt ())
    return false;

  dir = ParseDirection (d.asString ());
  if (dir == proto::NONE)
    return false;
  steps = n.asUInt ();
  if (steps <= 0 || steps > 1000000)
    return false;

  return true;
}

(moves.cpp)

Walk the checks in order and notice how each one closes a door:

  1. isObject() — rejects "hello", 42, [1,2], null. A move is the inner value of the envelope ({"g":{"mv": <this> }}), and nothing forces it to be an object.
  2. obj.size() != 2 — rejects objects with extra keys: {"d":"k","n":2,"cheat":true} is invalid. Strictness here keeps the accepted move language minimal, so future rule extensions can't accidentally collide with junk that old GSPs silently accepted.
  3. isMember("d") / isMember("n") — exactly the two expected keys must be present.
  4. d.isString() and n.isUInt() — type checks. {"d":"k","n":"2"} (string number), {"d":"k","n":2.5} (float), and {"d":"k","n":-1} (negative) all fail here.
  5. ParseDirection maps the eight valid one-letter strings to enum values and returns NONE for anything else — so {"d":"q","n":2} fails.
  6. Bounds: steps must be between 1 and 1,000,000. (steps is unsigned, so steps <= 0 is effectively the zero check.) The upper bound prevents a single move from scheduling effectively-infinite movement.

The direction table is vim/nethack-style:

proto::Direction
ParseDirection (const std::string& str)
{
  if (str == "l")
    return proto::RIGHT;
  if (str == "h")
    return proto::LEFT;
  if (str == "k")
    return proto::UP;
  if (str == "j")
    return proto::DOWN;
  if (str == "u")
    return proto::RIGHT_UP;
  if (str == "n")
    return proto::RIGHT_DOWN;
  if (str == "y")
    return proto::LEFT_UP;
  if (str == "b")
    return proto::LEFT_DOWN;

  return proto::NONE;
}

(moves.cpp)

The other two helpers are pure lookup tables. GetDirectionOffset turns a direction into a (dx, dy) unit step — UP is (0, 1), LEFT_DOWN is (-1, -1), and so on — and DirectionToString produces the human-readable names ("up", "left-down") you saw in the state JSON in Part 2.

A crucial design point: an invalid move is ignored, not rejected. There is no way to reject it — the transaction is already in a block, immutably. All ParseMove returning false means is that this particular game pretends the move never happened. Every honest GSP runs the same parser and ignores the same garbage, so the state stays consistent.

4. logic.cpp — the game rules

MoverLogic subclasses xaya::GameLogic and implements the three *Internal callbacks plus GameStateToJson (logic.hpp). The Internal variants are what subclasses override; the GameLogic base class wraps them with a per-call context, which is how GetContext().GetChain() works below.

4.1 GetInitialStateInternal — where and how the game begins

GameStateData
MoverLogic::GetInitialStateInternal (unsigned& height, std::string& hashHex)
{
  const Chain chain = GetContext ().GetChain ();
  switch (chain)
    {
    case Chain::MAIN:
      height = 125000;
      hashHex
          = "2aed5640a3be8a2f32cdea68c3d72d7196a7efbfe2cbace34435a3eef97561f2";
      break;

    // ... TEST and REGTEST cases ...

    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);
    }

  /* In all cases, the initial game state is just empty.  */
  proto::GameState state;

  GameStateData result;
  CHECK (state.SerializeToString (&result));

  return result;
}

(logic.cpp)

This answers three questions per chain: at which block does the game exist (height), which exact block is that (hashHex), and what is the state at that moment (the return value — here a freshly constructed, empty GameState).

The Chain::POLYGON case is the patch you applied in Part 1 — stock mover only knows the Xaya Core chains (MAIN/TEST/REGTEST) and dies in the default: branch with LOG(FATAL) << "Unexpected chain" on anything else. Two things about the patch are worth restating:

  • The height defines your game's genesis. Moves before block 88,365,000 are invisible to this game forever. When you create your own game, you pick a recent block so your GSP doesn't have to chew through years of irrelevant history.
  • The empty hash is a convenience. Pinning the exact hash (as the MAIN case does) is the paranoid option; with an empty string, libxayagame asks the connected XayaX bridge for the hash at that height and accepts it — in Part 1 you saw the corresponding log line Game did not specify genesis hash, retrieved 701ca380fb3e....

See Other Chains for the full xaya::Chain enum — this switch is the only chain-specific code in the entire game.

4.2 ProcessForwardInternal — one block forward

This is the heart of every GSP: given the previous state and one block's worth of data, produce the next state — and the undo data needed to take it back. Mover's per-block rules, as code, are exactly two loops:

flowchart TD
    A["Parse oldState protobuf"] --> B["Loop 1: for each move in blockData[#quot;moves#quot;]"]
    B --> C{"ParseMove ok?"}
    C -- no --> D["LOG(WARNING) Ignoring invalid move; continue"]
    C -- yes --> E{"Player exists in state?"}
    E -- no --> F["Create at (0,0); undo: is_new = true"]
    E -- yes --> G["undo: previous_dir, previous_steps_left"]
    F --> H["Set dir + steps_left from the move"]
    G --> H
    H --> B
    B --> I["Loop 2: for each player in state"]
    I --> J{"dir == NONE?"}
    J -- yes --> I
    J -- no --> K["Step 1 unit via GetDirectionOffset; steps_left -= 1"]
    K --> L{"steps_left == 0?"}
    L -- yes --> M["undo: finished_dir = dir; dir = NONE"]
    L -- no --> I
    M --> I
    I --> N["Serialize undo + new state; return"]
Loading

Loop 1 — apply the block's moves. Each entry in blockData["moves"] carries (among other fields) the sender's name in "name"without the p/ prefix, so p/xsv5bob arrives as "xsv5bob" — and the game-specific move data in "move", already unwrapped from the {"g":{"mv":...}} envelope by libxayagame:

  /* Go over all moves, adding/updating players in the state.  */
  for (const auto& m : blockData["moves"])
    {
      const std::string& name = m["name"].asString ();
      const Json::Value& obj = m["move"];

      proto::Direction dir;
      unsigned steps;
      if (!ParseMove (obj, dir, steps))
        {
          LOG (WARNING) << "Ignoring invalid move:\n" << obj;
          continue;
        }

(logic.cpp)

Here is the defensive parser from section 3 in action — and the moment the undo data starts being built. For every player touched by a move, the code records what is about to be destroyed:

      const auto mi = state.mutable_players ()->find (name);
      const bool isNew = (mi == state.mutable_players ()->end ());
      proto::PlayerState* p;
      if (isNew)
        p = &(*state.mutable_players ())[name];
      else
        p = &mi->second;

      proto::PlayerUndo& u = (*undo.mutable_players ())[name];
      if (isNew)
        {
          u.set_is_new (true);
          p->set_x (0);
          p->set_y (0);
        }
      else
        {
          u.set_previous_dir (p->dir ());
          u.set_previous_steps_left (p->steps_left ());
        }

      p->set_dir (dir);
      p->set_steps_left (steps);
    }

(logic.cpp)

New players spawn at the origin (0, 0) and get is_new in the undo data (so a rollback can delete them). Existing players get their old direction and steps saved (so a rollback can restore them) before the move overwrites both.

Loop 2 — move everyone. After moves are applied, every player with a direction takes exactly one step:

  /* Go over all players in the state and move them.  */
  for (auto& mi : *state.mutable_players ())
    {
      const std::string& name = mi.first;
      proto::PlayerState& p = mi.second;

      if (p.dir () == proto::NONE)
        continue;

      CHECK (p.steps_left () > 0);
      int dx, dy;
      GetDirectionOffset (p.dir (), dx, dy);
      p.set_x (p.x () + dx);
      p.set_y (p.y () + dy);

      p.set_steps_left (p.steps_left () - 1);
      if (p.steps_left () == 0)
        {
          proto::PlayerUndo& u = (*undo.mutable_players ())[name];
          u.set_finished_dir (p.dir ());
          p.set_dir (proto::NONE);
        }
    }

(logic.cpp)

When a counter hits zero the direction is cleared — but first stashed in finished_dir, because that transition is otherwise irreversible. (One player can have both previous_dir and finished_dir set in the same block: send a move with n: 1 and the new direction is applied in loop 1, used for one step in loop 2, and immediately retired. The proto file's comment on finished_dir discusses exactly this case.)

A note for your own games: in mover, each player's update in loop 2 is independent of every other player, so the (unspecified) iteration order of the protobuf map cannot change the result. The moves in loop 1 come in the deterministic order they appear in the block. If your game has players interacting — combat, collisions, shared resources — you must impose an explicit deterministic order yourself, or different GSPs may compute different states. Determinism is not optional; it is the consensus.

Finally both protobufs are serialized back into opaque byte strings for libxayagame:

  CHECK (undo.SerializeToString (&undoData));
  GameStateData newState;
  CHECK (state.SerializeToString (&newState));

  LOG (INFO) << "Processed " << blockData["moves"].size () << " moves forward, "
             << "new state has " << state.players_size () << " players";

(logic.cpp)

That log line is the one you watched for in Part 2. When your p/-name's move confirmed, the GSP logged, for real:

Processed 1 moves forward, new state has 1 players

Trace the Part 2 move through the code. You sent {"d":"k","n":2} as a brand-new player:

Block Loop 1 Loop 2 State after Undo for this block
N (move confirms) create at (0,0); dir=UP, steps_left=2 step to (0,1); steps_left=1 (0,1), up, 1 is_new: true
N+1 no moves step to (0,2); steps_left=0dir=NONE (0,2) finished_dir: UP

That's why the player moves one step in the move's own block and comes to rest at (0, 2) — matching exactly the {"players":{"xsv5bob":{"x":0,"y":2}}} you queried over RPC in Part 2.

4.3 ProcessBackwardsInternal — the inverse function

Polygon (like every chain) reorganizes occasionally: a block your GSP processed stops being part of the best chain. libxayagame detects this and calls ProcessBackwardsInternal with the state after the block, the block data, and the undo data that ProcessForwardInternal produced for that exact block. The contract is precisely an inverse function:

ProcessBackwards( ProcessForward(S, block, →undo), block, undo )  ==  S      (bit for bit)

Mover's implementation undoes the two forward loops in reverse, using the undo data wherever the forward step destroyed information:

  std::set<std::string> playersToRemove;
  for (auto& mi : *state.mutable_players ())
    {
      const std::string& name = mi.first;
      proto::PlayerState& p = mi.second;

      const proto::PlayerUndo* u = nullptr;
      const auto undoIt = undo.players ().find (name);
      if (undoIt != undo.players ().end ())
        u = &undoIt->second;

      /* If the player was new, just mark it for removal right away.  */
      if (u != nullptr && u->is_new ())
        {
          playersToRemove.insert (name);
          continue;
        }

      /* Restore "finished directions".  */
      if (u != nullptr && u->has_finished_dir ())
        {
          CHECK (p.dir () == proto::NONE && p.steps_left () == 0);
          p.set_dir (u->finished_dir ());
        }

      /* Undo move if the player is moving.  */
      if (p.dir () != proto::NONE)
        {
          p.set_steps_left (p.steps_left () + 1);

          int dx, dy;
          GetDirectionOffset (p.dir (), dx, dy);
          p.set_x (p.x () - dx);
          p.set_y (p.y () - dy);
        }

      /* Restore direction and steps_left from explicit change.  */
      if (u != nullptr)
        {
          if (u->has_previous_dir ())
            p.set_dir (u->previous_dir ());
          if (u->has_previous_steps_left ())
            p.set_steps_left (u->previous_steps_left ());
        }
    }

  /* Finalise removal of players.  This is done in a separate step to avoid
     issues with invalidated iterators.  */
  for (const auto& nm : playersToRemove)
    state.mutable_players ()->erase (nm);

(logic.cpp)

Read it as four inverse operations, applied per player in the right order:

  1. Player was created this block? → delete them. (Deferred to a second pass via playersToRemove, because erasing map entries while iterating the same map invalidates iterators.) This inverts the is_new spawn from forward loop 1.
  2. Direction was retired this block? → put it back from finished_dir. Now the player "is moving" again, which the next step needs. Note the CHECK: if undo data claims the direction finished, the state must show NONE/0 — a corrupted state or mismatched undo blob crashes loudly instead of silently diverging.
  3. Player has a direction? → step them backwards (x - dx, y - dy) and increment steps_left. This inverts forward loop 2's one step. No undo data needed: movement is self-inverting once the direction is known — which is exactly why positions never appear in UndoData.
  4. The move overwrote dir/steps this block? → restore the saved previous_dir / previous_steps_left. This inverts forward loop 1's assignment, and it must come last, after the position rollback that used the block's (new) direction. Here the proto2 has_*() presence checks earn their keep: "field not set" means "nothing was overwritten".

Run the Part 2 trace backwards to convince yourself. Undoing block N+1: the player sits at (0, 2) with no direction; undo says finished_dir: UP → direction restored to up → step back to (0, 1), steps_left becomes 1. Exactly the state after block N. Undoing block N: undo says is_new → the player is removed. Empty state — the genesis we started from.

You will probably never see this run on Polygon (reorgs are rare and shallow), but libxayagame calls it whenever needed, and the --enable_pruning=1000 flag from Part 1 controls how many blocks' undo data is retained for it. A reorg deeper than the pruning window cannot be unwound from undo data — the GSP would have to resync from genesis.

5. GameStateToJson — what RPC clients see

The internal state is binary protobuf; nobody outside the daemon ever sees it. When a client calls getcurrentstate (see the GSP RPC Interface), libxayagame asks the game to render the state as JSON:

Json::Value
MoverLogic::GameStateToJson (const GameStateData& encodedState)
{
  proto::GameState state;
  CHECK (state.ParseFromString (encodedState));

  Json::Value players(Json::objectValue);
  for (const auto& playerEntry : state.players ())
    {
      const proto::PlayerState& p = playerEntry.second;

      Json::Value playerJson(Json::objectValue);
      playerJson["x"] = p.x ();
      playerJson["y"] = p.y ();
      if (p.dir () != proto::NONE)
        {
          playerJson["dir"] = DirectionToString (p.dir ());
          playerJson["steps"] = static_cast<int> (p.steps_left ());
        }

      players[playerEntry.first] = playerJson;
    }

  Json::Value res(Json::objectValue);
  res["players"] = players;

  return res;
}

(logic.cpp)

Idle players are rendered as just {"x": ..., "y": ...}; moving players additionally get "dir" (the long names from DirectionToString"up", not "k") and "steps". This is exactly the gamestate field you saw in Part 2:

{"players":{"xsv5bob":{"x":0,"y":2}}}

The conversion runs only on request — it's a view, never part of consensus. That separation matters for bigger games: the internal representation is optimized for processing (protobuf here; an SQLite database for SQLiteGame-based games), while the JSON is shaped for whatever clients need.

6. pending.cpp — pending moves (idle on Polygon)

Confirmed state lags the mempool: a move that has been broadcast but not yet mined is invisible to ProcessForward. For snappier UIs, libxayagame supports an optional PendingMoveProcessor that tracks unconfirmed moves, exposed via the getpendingstate RPC method.

Mover's implementation keeps, per name, the latest pending move and the projected destination if it confirmed and ran to completion:

  int dx, dy;
  GetDirectionOffset (dir, dx, dy);
  Json::Value target(Json::objectValue);
  target["x"] = x + static_cast<int> (steps) * dx;
  target["y"] = y + static_cast<int> (steps) * dy;

  Json::Value playerState(Json::objectValue);
  playerState["dir"] = DirectionToString (dir);
  playerState["steps"] = static_cast<int> (steps);
  playerState["target"] = target;

  pending[name] = playerState;

(pending.cpp, in AddPendingMoveInternal)

The class overrides three hooks (pending.hpp): AddPendingMove (a new mempool move arrived — note it reuses the same ParseMove validation and logs Invalid pending move for junk), Clear (a new block arrived; the baseline state changed, so projections are rebuilt), and ToJson (serve getpendingstate). AddPendingMove calls GetConfirmedState() to read the player's current confirmed position as the projection's starting point.

On Polygon, all of this stays idle. Pending tracking needs a feed of mempool transactions, which the original Xaya Core daemon provides over ZMQ — but the XayaX bridge does not. This is verified behavior: start moverd with --pending_moves=true against XayaX and it logs

Not subscribing to pending moves

and runs normally, with getpendingstate simply staying empty. That's why Part 1's compose file sets --pending_moves=false. The code is still worth reading: if you target Xaya Core (or a future bridge with mempool support), this is the pattern, and it costs you nothing to design for it.

7. main.cpp — wiring it into libxayagame

The entry point contains no game logic at all — it turns command-line flags into a GameDaemonConfiguration and hands everything to DefaultMain. The flags are declared with gflags:

DEFINE_string (xaya_rpc_url, "",
               "URL at which Xaya Core's JSON-RPC interface is available");
DEFINE_int32 (xaya_rpc_protocol, 1,
              "JSON-RPC version for connecting to Xaya Core");
// ...
DEFINE_int32 (enable_pruning, -1,
              "if non-negative (including zero), enable pruning of old undo"
              " data and keep as many blocks as specified by the value");

DEFINE_string (storage_type, "memory",
               "the type of storage to use for game data (memory or sqlite)");
DEFINE_string (datadir, "",
               "base data directory for game data (will be extended by the"
               " game ID and chain); must be set if --storage_type is not"
               " memory");

DEFINE_bool (pending_moves, true,
             "whether or not pending moves should be tracked");

(main.cpp)

You used every one of these in Part 1: --xaya_rpc_url=http://xayax:8000, --xaya_rpc_protocol=2 (XayaX speaks JSON-RPC 2.0), --game_rpc_port=8600, --storage_type=sqlite, --datadir=/xayagame, --enable_pruning=1000, --pending_moves=false. (The storage_type help string says "memory or sqlite", but libxayagame's defaultmain actually also supports lmdb.) Note how --datadir is "extended by the game ID and chain" — that's the /xayagame/mv/polygon/storage.sqlite layout you saw on the volume in Part 1, which lets one data directory host multiple games and chains without collisions.

After flag validation, main fills the config and starts the engine:

  xaya::GameDaemonConfiguration config;
  config.XayaRpcUrl = FLAGS_xaya_rpc_url;
  config.XayaJsonRpcProtocol = FLAGS_xaya_rpc_protocol;
  config.XayaRpcWait = FLAGS_xaya_rpc_wait;
  if (FLAGS_game_rpc_port != 0)
    {
      config.GameRpcServer = xaya::RpcServerType::HTTP;
      config.GameRpcPort = FLAGS_game_rpc_port;
      config.GameRpcListenLocally = FLAGS_game_rpc_listen_locally;
    }
  config.EnablePruning = FLAGS_enable_pruning;
  config.StorageType = FLAGS_storage_type;
  config.DataDirectory = FLAGS_datadir;

  mover::PendingMoves pending;
  if (FLAGS_pending_moves)
    config.PendingMoves = &pending;

  mover::MoverLogic rules;
  const int res = xaya::DefaultMain (config, "mv", rules);

(main.cpp)

That single DefaultMain(config, "mv", rules) call is doing an enormous amount of work: it connects to XayaX, auto-detects the ZMQ block endpoint, opens (or creates) the storage, runs the initial sync in batches, serves the GSP's own JSON-RPC interface, detects reorgs and drives your ProcessForward/ProcessBackwards callbacks, prunes old undo data, and reconnects on failures. The second argument is the game ID: it tells libxayagame to deliver only moves wrapped as {"g":{"mv":...}}, and it names the storage subdirectory. Your game's identity, in one string.

One build detail from Part 1: the file starts with #include "config.h", normally generated by the upstream autotools build, used only for PACKAGE_VERSION in gflags::SetVersionString. Our Dockerfile fakes it with a one-liner:

echo '#define PACKAGE_VERSION "polygon-tutorial"' > config.h

8. Testing

The upstream mover in libxayagame ships with two layers of tests (not included in our stripped-down tutorial copy):

  • Unit testsmover/logic_tests.cpp, mover/moves_tests.cpp, and mover/pending_tests.cpp, written with GoogleTest. They exercise the parsing and state-transition logic directly with hand-built inputs — fast, no chain involved. This is the right place to pin down the inverse-function contract from section 4.3 for your own game: assert that processing a block forward and then backward reproduces the original state bit for bit.
  • Integration testsmover/gametest/, Python tests built on libxayagame's xayagametest framework. These spin up a regtest Xaya Core node plus the real moverd binary, mine blocks on demand, send actual moves, and even force reorgs to exercise the backward path end to end against a fully local, deterministic chain.

If you build your own game, copy this structure: unit-test the rules (especially forward/backward symmetry and the parser's rejection cases), and use xayagametest with regtest Xaya Core for full-stack determinism tests — reorgs included, no real chain needed. And remember from Part 2 that live testing on Polygon is genuinely cheap: a real on-chain move costs well under $0.01 in POL, so "test in production against your own game ID" is a perfectly reasonable final check.

9. Make it your own

You now understand every line that makes mover a game. The fastest way to learn GSP development is to mutate it:

  • New movement rules — add a speed field to moves ({"d":"k","n":10,"v":3} for 3 steps per block), or a teleport move, or momentum/turning costs. You'll touch mover.proto (state fields), moves.cpp (parsing — keep it strict!), and both Process* functions (and their undo handling — every new irreversible change needs an undo field).
  • Obstacles and a bounded map — reject or clamp movement into walls in loop 2. A static map needs no state changes; destructible obstacles do (and undo data for them).
  • Scores and interaction — first player to reach a target coordinate scores a point; players colliding bounce or capture each other. The moment players interact, remember the determinism warning from section 4.2: process interactions in an explicitly defined order.
  • Attach value — the move() contract call can carry a WCHI payment (amount/receiver, see Names and Moves), which opens the door to game designs where certain moves require a payment or stake.

One rule binds all of this: any change to the rules creates a different game. Two GSPs only agree if they run byte-identical logic, so your variant must not call itself mv — pick your own game ID, change the second argument of DefaultMain, pick your own genesis height in GetInitialStateInternal, and have players target your ID in the move envelope ({"g":{"yourgame":{...}}}). Mover's state at your genesis is empty, so a fresh ID on a fresh height is a clean slate — exactly how mv itself started.

From here, good next steps are the GSP RPC Interface reference for building clients against your daemon, Game Channels for where off-chain real-time play fits in, and Other Chains if you want your game to run beyond Polygon.

Clone this wiki locally