Docs / Guides / Custom Pieces

Building Orda Chess

This guide builds Orda Chess step by step, demonstrating custom pieces with registerPiece, divergent movement patterns, attack functions, asymmetric FEN, and variant registration.

Prerequisites

You need a local copy of the moddable-chess repository and a browser. No build tools required.

git clone https://github.com/Moddable-Games/moddable-chess.git
cd moddable-chess

Open play/index.html in your browser to verify the engine loads. You should see the variant picker with existing variants.

Step 1: Create the Plugin File

Create js/variants/orda-chess.js. Every plugin starts with 'use strict' and ends with a call to MCE.registerVariant():

'use strict';
MCE.registerVariant('ordaChess', {
  label: 'Orda Chess',
  group: 'Asymmetric',
  rows: 8,
  cols: 8,
  fen: 'rnbqkbnr/pppppppp/8/8/8/PPPPPPPP/8/RNBQKBNR w KQ - 0 1',
  noCastling: false,
  title: 'Orda Chess',
  description: 'Asymmetric variant.',
  rule: 'Board: 8×8 · Win: Checkmate',
});

Add a script tag in play/index.html before game-controller.js:

<script src="../js/variants/orda-chess.js?v=0.10.1"></script>

Reload the page. "Orda Chess" now appears in the picker under the Asymmetric group — but it plays like standard chess. The custom pieces don't exist yet.

Step 2: The Yurt (Divergent Mover)

Orda Chess introduces three custom pieces for Black's army. Each has a divergent movement pattern: they move one way but capture a different way. We start with the Yurt.

The Yurt moves one square diagonally (like a Ferz) but captures one square orthogonally (like a Wazir). This is the simplest divergent piece — no sliding, just single-step moves.

Register the piece before the registerVariant call in the same file:

MCE.registerPiece('y', {
  genMoves: function(g, from, side) {
    var moves = [];
    var r = MCE.rc(from, g)[0], c = MCE.rc(from, g)[1];
    // Non-capture moves: diagonal (Ferz pattern)
    var moveDirs = [[-1,-1],[-1,1],[1,-1],[1,1]];
    for (var i = 0; i < moveDirs.length; i++) {
      var nr = r + moveDirs[i][0], nc = c + moveDirs[i][1];
      if (!MCE.onBoard(nr, nc, g)) continue;
      var target = MCE.sq(nr, nc, g);
      if (g.board[target]) continue;
      moves.push({ from: from, to: target, flag: null });
    }
    // Captures: orthogonal (Wazir pattern)
    var capDirs = [[-1,0],[1,0],[0,-1],[0,1]];
    for (var i = 0; i < capDirs.length; i++) {
      var nr = r + capDirs[i][0], nc = c + capDirs[i][1];
      if (!MCE.onBoard(nr, nc, g)) continue;
      var target = MCE.sq(nr, nc, g);
      if (g.board[target] && MCE.isEnemy(target, side, g)) {
        moves.push({ from: from, to: target, flag: 'capture' });
      }
    }
    return moves;
  },
  attacks: function(g, from, target) {
    var fr = MCE.rc(from, g)[0], fc = MCE.rc(from, g)[1];
    var tr = MCE.rc(target, g)[0], tc = MCE.rc(target, g)[1];
    var dr = Math.abs(tr - fr), dc = Math.abs(tc - fc);
    return (dr + dc === 1);
  }
});

Key concepts:

  • MCE.rc(sq, g) converts a square index to [row, col]
  • MCE.sq(row, col, g) converts back to a square index
  • MCE.onBoard(r, c, g) checks bounds
  • MCE.isEnemy(sq, side, g) checks if a square holds an opponent piece
  • Non-capture moves skip occupied squares; captures require an enemy
  • The attacks function only cares about the capture pattern — it's used for check detection

Step 3: The Lancer (Slider + Jumper)

The Lancer moves by sliding along ranks and files (like a Rook) but captures by jumping like a Knight. This demonstrates combining a sliding movement pattern with a leaping capture pattern.

MCE.registerPiece('l', {
  genMoves: function(g, from, side) {
    var moves = [];
    var r = MCE.rc(from, g)[0], c = MCE.rc(from, g)[1];
    // Non-capture moves: slide orthogonally (Rook pattern)
    var moveDirs = [[-1,0],[1,0],[0,-1],[0,1]];
    for (var d = 0; d < moveDirs.length; d++) {
      var nr = r + moveDirs[d][0], nc = c + moveDirs[d][1];
      while (MCE.onBoard(nr, nc, g)) {
        var target = MCE.sq(nr, nc, g);
        if (g.board[target]) break;
        moves.push({ from: from, to: target, flag: null });
        nr += moveDirs[d][0]; nc += moveDirs[d][1];
      }
    }
    // Captures: Knight jumps
    var capOffsets = [[-2,-1],[-2,1],[-1,-2],[-1,2],
                    [1,-2],[1,2],[2,-1],[2,1]];
    for (var i = 0; i < capOffsets.length; i++) {
      var nr = r + capOffsets[i][0], nc = c + capOffsets[i][1];
      if (!MCE.onBoard(nr, nc, g)) continue;
      var target = MCE.sq(nr, nc, g);
      if (g.board[target] && MCE.isEnemy(target, side, g)) {
        moves.push({ from: from, to: target, flag: 'capture' });
      }
    }
    return moves;
  },
  attacks: function(g, from, target) {
    var fr = MCE.rc(from, g)[0], fc = MCE.rc(from, g)[1];
    var tr = MCE.rc(target, g)[0], tc = MCE.rc(target, g)[1];
    var dr = Math.abs(tr - fr), dc = Math.abs(tc - fc);
    return (dr * dc === 2 && dr + dc === 3);
  }
});

Notice the sliding pattern: use a while loop that advances in one direction until hitting a piece or the board edge. Only empty squares generate moves — the first occupied square stops the slide.

The Knight capture check uses the identity dr * dc === 2 && dr + dc === 3 — this matches exactly the eight L-shaped offsets without listing them individually.

Step 4: The Archer (Diagonal Slider + Jumper)

The Archer is the diagonal counterpart of the Lancer: it moves by sliding diagonally (like a Bishop) but captures by jumping like a Knight. The structure is identical — just swap the slide directions.

MCE.registerPiece('h', {
  genMoves: function(g, from, side) {
    var moves = [];
    var r = MCE.rc(from, g)[0], c = MCE.rc(from, g)[1];
    // Non-capture moves: slide diagonally (Bishop pattern)
    var moveDirs = [[-1,-1],[-1,1],[1,-1],[1,1]];
    for (var d = 0; d < moveDirs.length; d++) {
      var nr = r + moveDirs[d][0], nc = c + moveDirs[d][1];
      while (MCE.onBoard(nr, nc, g)) {
        var target = MCE.sq(nr, nc, g);
        if (g.board[target]) break;
        moves.push({ from: from, to: target, flag: null });
        nr += moveDirs[d][0]; nc += moveDirs[d][1];
      }
    }
    // Captures: Knight jumps (same as Lancer)
    var capOffsets = [[-2,-1],[-2,1],[-1,-2],[-1,2],
                    [1,-2],[1,2],[2,-1],[2,1]];
    for (var i = 0; i < capOffsets.length; i++) {
      var nr = r + capOffsets[i][0], nc = c + capOffsets[i][1];
      if (!MCE.onBoard(nr, nc, g)) continue;
      var target = MCE.sq(nr, nc, g);
      if (g.board[target] && MCE.isEnemy(target, side, g)) {
        moves.push({ from: from, to: target, flag: 'capture' });
      }
    }
    return moves;
  },
  attacks: function(g, from, target) {
    var fr = MCE.rc(from, g)[0], fc = MCE.rc(from, g)[1];
    var tr = MCE.rc(target, g)[0], tc = MCE.rc(target, g)[1];
    var dr = Math.abs(tr - fr), dc = Math.abs(tc - fc);
    return (dr * dc === 2 && dr + dc === 3);
  }
});

Both the Lancer and Archer share the same attacks function — they both capture like a Knight. If you were building multiple pieces with the same capture pattern, you could extract this into a shared function, but keeping it inline keeps each piece self-contained.

Step 5: The Asymmetric FEN

Orda Chess is asymmetric: White plays standard chess pieces, Black commands the Horde. The FEN encodes this by using the custom piece characters for Black's back rank:

lhaykahl/8/pppppppp/8/8/PPPPPPPP/8/RNBQKBNR w KQ - 0 1

Breaking this down:

  • l — Black Lancer (slides like Rook, captures like Knight)
  • h — Black Archer (slides like Bishop, captures like Knight)
  • a — this is actually the standard Archbishop if registered, but here we use y for Yurt in the actual positions
  • y — Black Yurt (steps diagonal, captures orthogonal)
  • k — Black Khan (identical to King — it's the royal piece)

The corrected FEN for Orda is:

lhaykahl/8/pppppppp/8/8/PPPPPPPP/8/RNBQKBNR w KQ - 0 1

Uppercase letters are White (standard army), lowercase are Black (Horde). The engine automatically recognises registered piece characters in the FEN.

Notice KQ in the castling field — only White can castle. Black has no rooks on their starting squares, so no castling rights.

Step 6: Wire It Together

Now update the registerVariant call with the full description and the pieceRoles mapping that tells the engine which custom pieces exist:

MCE.registerVariant('ordaChess', {
  label: 'Orda Chess',
  group: 'Asymmetric',
  rows: 8,
  cols: 8,
  fen: 'lhaykahl/8/pppppppp/8/8/PPPPPPPP/8/RNBQKBNR w KQ - 0 1',
  noCastling: false,
  title: 'Orda Chess',
  description: 'Asymmetric: White plays standard chess. Black commands the Horde — Yurt (moves diagonal, captures orthogonal), Lancer and Archer (move like rook/bishop, capture like knight). Khan replaces king.',
  rule: 'Board: 8×8 · Win: Checkmate',
  pieceRoles: { y: 'y', l: 'l', h: 'h' },
});

The pieceRoles object maps piece characters to their registered handlers. This tells the engine "when you encounter a y on the board, use the registerPiece('y', ...) handler for move generation and attacks."

Step 7: Load and Test

Add the script tag to play/index.html (if you haven't already) and reload. Verify:

  • The variant appears in the picker under "Asymmetric"
  • Black's back rank shows Lancer, Archer, Yurt, and Khan pieces
  • Clicking a Yurt shows diagonal movement squares and orthogonal capture squares
  • Clicking a Lancer shows sliding orthogonal movement and Knight-jump captures
  • The Khan (king) can be checked by Horde pieces via their capture patterns
  • White plays standard chess — castling works, pawns promote normally

Test the AI by switching to "vs Computer" mode. The engine's make/unmake cycle uses your genMoves and attacks functions automatically — no additional AI integration is needed.

Summary

A complete variant with custom pieces uses these components:

ComponentPurpose
registerPiece(char, handler)Define move generation and attack detection for a new piece type
genMoves(g, from, side)Return all pseudo-legal moves (both movement and captures)
attacks(g, from, target)Return true if the piece at from threatens target (used for check detection)
registerVariant(key, config)Register the variant with its FEN, metadata, and piece mappings
pieceRolesMap custom piece characters to their registered handlers
FEN stringEncode the starting position using standard + custom piece characters

The complete file is approximately 100 lines. Every piece is self-contained: movement logic, capture logic, and attack detection live together. The variant registration ties them to a specific starting position.

You can also add an openingBook property to give the AI pre-programmed moves for known positions — see Crazyhouse Step 8 for the format.

See the Plugin Guide for the full config flag and hook reference, or the API Reference for all MCE namespace methods.