Docs / Plugin Guide

Plugin Guide

Every variant is a single JavaScript file that calls MCE.registerVariant(). No core engine code needs to be modified.

Architecture

The plugin system follows a strict rule: all variant-specific logic lives in plugin files. The core engine provides generic hooks — plugins consume them. If a variant needs behaviour the hooks can't express, the hook API is extended rather than adding hardcoded logic to core.

Each plugin file is a standalone .js file in js/variants/. It calls MCE.registerVariant(key, config) to register itself. The engine auto-discovers registered variants and makes them available in the picker.

Minimal Plugin

The simplest possible variant — standard rules with one flag change:

'use strict';
MCE.registerVariant('noCastling', {
  label: 'No Castling',
  group: 'Classic',
  rows: 8,
  cols: 8,
  fen: null,
  noCastling: true,
  title: 'No Castling',
  description: 'Standard chess with castling disabled.',
  rule: 'Board: 8×8 · Win: Checkmate',
});

Setting fen: null uses the standard starting position. The noCastling: true flag disables castling moves without any custom code.

Config Properties

Every plugin must provide these required properties:

PropertyTypeDescription
labelstringDisplay name in the variant picker
groupstringPicker group (Classic, Tactical, Alternate Rules, Large Boards)
rowsnumberBoard height (5–12)
colsnumberBoard width (5–12)
fenstring|nullStarting FEN, or null for standard position
titlestringFull name shown in the description panel
descriptionstringRules explanation shown to the player
rulestringOne-line summary (e.g. "Board: 8×8 · Win: Checkmate")

Config Flags

Boolean or value flags that modify engine behaviour without custom code:

FlagTypeEffect
noCastlingbooleanDisables castling
noEnPassantbooleanDisables en passant captures
noPromotionbooleanDisables pawn promotion
noCheckbooleanSkips check validation (king is capturable)
torpedobooleanPawns can double-push from any rank
checkThresholdnumberNumber of checks to win (use with winCondition)
stalemateMeaningstring'win' makes stalemate a win for the stalemated side
promotionPiecesarrayOverride promotion choices (e.g. ['q','r','b','n','a','c'])
royalPiecestringWhich piece type is royal (default: 'k')
pieceRolesobjectSwap piece roles (e.g. { n: 'k', k: 'n' } for Knightmate)
pawnDirectionfunction(side) => direction — override pawn march direction
pawnStartRowfunction(side) => row — override double-push start row
maxMovesPerTurnobject{ w: 2, b: 1 } — moves per turn per side
progressiveMovenumberInitial move count for progressive variants
promotionRankfunction(side) => row — override which row triggers pawn promotion (default: row 0 for white, last row for black)
pawnMoveStylestring'standard' (default) or 'berolina' — Berolina pawns move diagonally and capture straight
divergentPiecesobjectMap piece types to separate move/capture patterns. Each entry: { move, moveStyle, capture, captureStyle }
wrapFilesbooleanFiles wrap (a-file connects to h-file, cylinder geometry)
wrapRanksbooleanRanks wrap (rank 1 connects to rank 8, toroidal with wrapFiles)

Example: Flags only

MCE.registerVariant('breakthrough', {
  label: 'Breakthrough',
  group: 'Alternate Rules',
  rows: 7, cols: 7,
  fen: 'ppppppp/ppppppp/7/7/7/PPPPPPP/PPPPPPP w - - 0 1',
  noCastling: true,
  noEnPassant: true,
  noPromotion: true,
  title: 'Breakthrough',
  description: 'Race a pawn to the far rank to win.',
  rule: 'Board: 7×7 · Win: Reach far rank',
  winCondition: function(g) { /* ... */ },
});

Example: Custom promotion rank

MCE.registerVariant('makruk', {
  // ...config...
  promotionRank: function(side) { return side === MCE.WHITE ? 2 : 5; },
  promotionPieces: ['m'], // promote to Met only
});

Example: Berolina pawns

MCE.registerVariant('berolinaChess', {
  // ...config...
  pawnMoveStyle: 'berolina', // pawns move diagonal, capture straight
});

Example: Divergent pieces

Map piece types to different move vs. capture patterns. moveStyle/captureStyle is 'slide' or 'jump'. Directions use the standard MCE direction arrays.

MCE.registerVariant('hoppelPoppel', {
  // ...config...
  divergentPieces: {
    n: { move: MCE.KNIGHT_OFFSETS, moveStyle: 'jump',
         capture: MCE.BISHOP_DIRS, captureStyle: 'slide' },
    b: { move: MCE.BISHOP_DIRS, moveStyle: 'slide',
         capture: MCE.KNIGHT_OFFSETS, captureStyle: 'jump' },
  },
});

Example: Wrap-around board

MCE.registerVariant('cylinderChess', {
  // ...config...
  wrapFiles: true, // a-file connects to h-file
});
MCE.registerVariant('toroidalChess', {
  // ...config...
  wrapFiles: true, // files wrap
  wrapRanks: true, // ranks wrap too (torus)
  noCastling: true,
  noEnPassant: true,
});

Hook Functions

Hooks let plugins inject custom logic at specific points in the game loop. All hooks are optional.

winCondition(g)

Called each turn to check for a variant-specific win. Return a status string (e.g. 'koth-w') to end the game, or null to continue.

MCE.registerVariant('kingOfTheHill', {
  // ...config...
  winCondition: function(g) {
    var center = [27, 28, 35, 36]; // d4, e4, d5, e5
    for (var i = 0; i < center.length; i++) {
      var p = g.board[center[i]];
      if (p && MCE.pieceType(p) === 'k') {
        var winner = MCE.pieceColor(p);
        if (winner !== g.turn) return 'koth-' + winner;
      }
    }
    return null;
  },
});

moveFilter(g, moves)

Filters the legal move array. Receives all legal moves, returns a subset. Used for forced captures, no-check constraints, etc.

// Antichess: captures are mandatory
moveFilter: function(g, moves) {
  var captures = moves.filter(function(m) {
    return g.board[m.to] || m.flag === 'ep';
  });
  return captures.length > 0 ? captures : moves;
}
// Racing Kings: no move may give or leave either side in check
moveFilter: function(g, moves) {
  return moves.filter(function(m) {
    var undo = MCE.makeMove(g, m);
    var legal = !MCE.inCheck(g, MCE.WHITE) && !MCE.inCheck(g, MCE.BLACK);
    MCE.unmakeMove(g, undo);
    return legal;
  });
}
// Makpong: king cannot move out of check (must block or capture)
moveFilter: function(g, moves) {
  if (MCE.inCheck(g, g.turn)) {
    return moves.filter(function(m) {
      return MCE.pieceType(g.board[m.from]) !== 'k';
    });
  }
  return moves;
}

beforeMove(g, move, undo)

Overrides how a move is physically executed on the board. The default behaviour moves the piece from move.from to move.to. This hook replaces that entirely.

// Rifle Chess: capturing piece stays on its own square
beforeMove: function(g, move, undo) {
  if (g.board[move.to] && move.flag !== 'ep') {
    g.board[move.to] = null;
    if (g.pieceData) g.pieceData[move.to] = null;
    g.board[move.from] = undo.piece;
  } else {
    g.board[move.to] = undo.piece;
    g.board[move.from] = null;
    if (g.pieceData) {
      g.pieceData[move.to] = undo.pieceData || null;
      g.pieceData[move.from] = null;
    }
  }
}
// Atomic Chess: captures cause explosions
beforeMove: function(g, move, undo) {
  if (g.board[move.to] && move.flag !== 'ep') {
    g.board[move.to] = null;
    g.board[move.from] = null;
    undo.exploded = [];
    var rc = MCE.rc(move.to, g);
    for (var dr = -1; dr <= 1; dr++) {
      for (var dc = -1; dc <= 1; dc++) {
        if (dr === 0 && dc === 0) continue;
        var r = rc[0] + dr, c = rc[1] + dc;
        if (!MCE.onBoard(r, c, g)) continue;
        var sq = MCE.sq(r, c, g);
        if (g.board[sq] && MCE.pieceType(g.board[sq]) !== 'p') {
          undo.exploded.push({ sq: sq, piece: g.board[sq] });
          g.board[sq] = null;
        }
      }
    }
  } else {
    g.board[move.to] = undo.piece;
    g.board[move.from] = null;
  }
}

turnLogic(g, undo)

Overrides turn advancement. The default is: increment fullmove on Black's turn, then flip the turn. Use this for multi-move turns, duck placement phases, etc.

// Marseillais: two moves per turn, check ends turn early
turnLogic: function(g, undo) {
  g.movesThisTurn++;
  undo.movesThisTurn = g.movesThisTurn - 1;
  var isFirstMove = g.fullmove === 1 && g.turn === MCE.WHITE;
  var opp = g.turn === MCE.WHITE ? MCE.BLACK : MCE.WHITE;
  var givesCheck = MCE.inCheck(g, opp);
  if (g.movesThisTurn >= 2 || isFirstMove || givesCheck) {
    if (g.turn === MCE.BLACK) g.fullmove++;
    MCE.advanceTurn(g);
    g.movesThisTurn = 0;
  }
}
// Duck Chess: two-phase turn (move piece, then place duck)
turnLogic: function(g, undo) {
  if (!g.duckPhase) {
    g.duckPhase = true;
  } else {
    g.duckPhase = false;
    if (g.turn === MCE.BLACK) g.fullmove++;
    MCE.advanceTurn(g);
  }
  undo.duckPhase = !g.duckPhase;
}

restoreState(g, undo)

Called during unmakeMove to restore variant-specific state. Anything stored on undo in turnLogic should be restored here.

restoreState: function(g, undo) {
  if (undo.movesThisTurn !== undefined) g.movesThisTurn = undo.movesThisTurn;
  if (undo.lastMovedSq !== undefined) g.lastMovedSq = undo.lastMovedSq;
}

init(g)

Called once when a game is created, after the FEN is loaded. Use for randomised positions or initialising variant-specific game state.

// Chess960: randomise the starting position
init: function(g) {
  MCE.loadFEN(g, MCE.randomFEN960());
  g.positionHistory = [MCE.positionKey(g)];
}

visibility(g, side)

Returns a Set of visible square indices for Fog of War style variants. Squares not in the set are hidden from the player.

visibility: function(g, side) {
  var visible = new Set();
  for (var i = 0; i < g.rows * g.cols; i++) {
    var p = g.board[i];
    if (p && MCE.pieceColor(p) === side) visible.add(i);
  }
  var tempG = Object.assign({}, g, { turn: side });
  var moves = MCE.pseudoLegalMoves(tempG);
  for (var j = 0; j < moves.length; j++) {
    visible.add(moves[j].to);
  }
  return visible;
}

afterMove(g, move, undo)

Called after the move is fully executed but before turn logic runs. Use for side effects like incrementing check counters or transforming pieces. Store original state in undo for reversal via restoreState.

// Einstein Chess: non-captures demote, captures promote
afterMove: function(g, move, undo) {
  var p = g.board[move.to];
  if (!p || MCE.pieceType(p) === 'k') return;
  var hierarchy = ['p', 'n', 'b', 'r', 'q'];
  var idx = hierarchy.indexOf(MCE.pieceType(p));
  var isCapture = move.flag === 'capture' || move.flag === 'ep';
  var newIdx = isCapture ? Math.min(idx + 1, 4) : Math.max(idx - 1, 0);
  if (newIdx !== idx) {
    undo.originalPiece = p; // save for restoreState
    var newType = hierarchy[newIdx];
    g.board[move.to] = MCE.pieceColor(p) === MCE.WHITE
      ? newType.toUpperCase() : newType;
  }
},
restoreState: function(g, undo) {
  if (undo.originalPiece) g.board[undo.to] = undo.originalPiece;
}

statusText(g, helpers)

Returns a custom status bar message, or null for default. helpers.nameFor(side) returns the player name for a side.

statusText: function(g, helpers) {
  if (g.movesThisTurn === 1) {
    return helpers.nameFor(g.turn) + ' — second move';
  }
  return null;
}

aiMoveCount(g)

Tells the AI how many sequential moves to make per turn. Default is 1.

aiMoveCount: function(g) {
  return g.maxMovesPerTurn[g.turn] || 1;
}

positionKey(g)

Custom position hashing for the transposition table. Required when pieces aren't distinguishable from FEN alone (e.g., all pieces are 'X' with types in pieceData).

positionKey: function(g) {
  var key = '';
  for (var i = 0; i < g.rows * g.cols; i++) {
    var pd = g.pieceData[i];
    key += pd ? pd.key[0] + pd.owner[0] : '.';
  }
  return key + ' ' + g.turn;
}

If not provided but ownershipMode === 'pieceData', MCE auto-generates a pieceData-aware hash. Only provide this hook if you need custom hashing beyond the default.

Using pendingAction in afterMove

Set g._pendingAction in afterMove to require a follow-up move before the turn ends:

afterMove: function(g, move, undo) {
  var pd = g.pieceData && g.pieceData[move.to];
  if (pd && pd.mustRetreat && undo.captured) {
    g._pendingAction = {
      from: move.to,
      filter: function(m) { return isAdjacent(m.to, move.to, g); },
      label: 'Must retreat after capture'
    };
  }
}

The engine automatically suppresses turn advancement, restricts legal moves to the follow-up, and restores state on undo. See Multi-Step Turns in the API reference for full details.

Variant Inheritance

A plugin can extend another variant using the extends property. All config from the parent is inherited, and the child can override any property.

MCE.registerVariant('fiveCheck', {
  extends: 'threeCheck',
  label: 'Five-Check',
  checkThreshold: 5,
  title: 'Five-Check',
  description: 'Like Three-Check but need five checks to win.',
  rule: 'Board: 8×8 · Win: 5 checks or checkmate',
  winCondition: function(g) {
    if (g.checkCount.w >= 5) return 'checkmate';
    if (g.checkCount.b >= 5) return 'checkmate';
    return null;
  },
});

Custom Pieces

If your variant introduces a new piece type, register it with MCE.registerPiece(). This goes in the same plugin file.

// Maharaja piece (Queen + Knight compound) — used as 'm'
MCE.registerPiece('m', {
  genMoves: function(g, from, side) {
    var moves = [];
    var pos = MCE.rc(from, g);
    // Queen directions (sliding)
    var dirs = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[-1,1],[1,-1],[1,1]];
    for (var d = 0; d < dirs.length; d++) {
      var nr = pos[0] + dirs[d][0], nc = pos[1] + dirs[d][1];
      while (MCE.onBoard(nr, nc, g)) {
        var target = MCE.sq(nr, nc, g);
        var tp = g.board[target];
        if (tp) {
          if (MCE.pieceColor(tp) !== side) moves.push({ from: from, to: target, flag: null });
          break;
        }
        moves.push({ from: from, to: target, flag: null });
        nr += dirs[d][0]; nc += dirs[d][1];
      }
    }
    // Knight jumps
    var jumps = [[-2,-1],[-2,1],[-1,-2],[-1,2],[1,-2],[1,2],[2,-1],[2,1]];
    for (var j = 0; j < jumps.length; j++) {
      var jr = pos[0] + jumps[j][0], jc = pos[1] + jumps[j][1];
      if (!MCE.onBoard(jr, jc, g)) continue;
      var jt = MCE.sq(jr, jc, g);
      if (!g.board[jt] || MCE.pieceColor(g.board[jt]) !== side)
        moves.push({ from: from, to: jt, flag: null });
    }
    return moves;
  },
  attacks: function(g, from, target) {
    // Return true if 'from' attacks 'target'
    var fp = MCE.rc(from, g), tp = MCE.rc(target, g);
    var dr = tp[0] - fp[0], dc = tp[1] - fp[1];
    // Knight attack
    if ((Math.abs(dr) === 2 && Math.abs(dc) === 1) ||
        (Math.abs(dr) === 1 && Math.abs(dc) === 2)) return true;
    // Queen attack (sliding, check for blockers)
    if (dr === 0 || dc === 0 || Math.abs(dr) === Math.abs(dc)) {
      var stepR = dr === 0 ? 0 : dr / Math.abs(dr);
      var stepC = dc === 0 ? 0 : dc / Math.abs(dc);
      var cr = fp[0] + stepR, cc = fp[1] + stepC;
      while (cr !== tp[0] || cc !== tp[1]) {
        if (g.board[MCE.sq(cr, cc, g)]) return false;
        cr += stepR; cc += stepC;
      }
      return true;
    }
    return false;
  }
});

The registerPiece handler requires two functions:

Use the piece character in your FEN string (lowercase for black, uppercase for white): '4M3' places a white Maharaja on e1.

Complete Examples

Simplest: flag-only variant

'use strict';
MCE.registerVariant('torpedo', {
  label: 'Torpedo Chess',
  group: 'Classic',
  rows: 8, cols: 8, fen: null,
  torpedo: true,
  title: 'Torpedo Chess',
  description: 'Pawns can double-push from any rank, not just the starting row.',
  rule: 'Board: 8×8 · Win: Checkmate',
});

Win condition variant

'use strict';
MCE.registerVariant('threeCheck', {
  label: 'Three-Check',
  group: 'Tactical',
  rows: 8, cols: 8, fen: null,
  checkThreshold: 3,
  title: 'Three-Check',
  description: 'Deliver three checks to win immediately.',
  rule: 'Board: 8×8 · Win: Checkmate or 3 checks',
  winCondition: function(g) {
    if (g.checkCount.w >= 3) return 'checkmate';
    if (g.checkCount.b >= 3) return 'checkmate';
    return null;
  },
});

Multi-move turn variant

'use strict';
MCE.registerVariant('monsterChess', {
  label: 'Monster Chess',
  group: 'Alternate Rules',
  rows: 8, cols: 8,
  fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1',
  title: 'Monster Chess',
  description: 'White gets two moves per turn; Black gets one.',
  rule: 'Board: 8×8 · Win: Checkmate',
  init: function(g) {
    g.maxMovesPerTurn = { w: 2, b: 1 };
    g.lastMovedSq = -1;
  },
  turnLogic: function(g, undo) {
    g.movesThisTurn++;
    undo.movesThisTurn = g.movesThisTurn - 1;
    undo.lastMovedSq = g.lastMovedSq;
    var max = g.maxMovesPerTurn[g.turn] || 1;
    var opp = g.turn === MCE.WHITE ? MCE.BLACK : MCE.WHITE;
    if (g.movesThisTurn >= max || MCE.inCheck(g, opp)) {
      if (g.turn === MCE.BLACK) g.fullmove++;
      MCE.advanceTurn(g);
      g.movesThisTurn = 0;
      g.lastMovedSq = -1;
    } else {
      g.lastMovedSq = undo.to;
    }
  },
  restoreState: function(g, undo) {
    if (undo.movesThisTurn !== undefined) g.movesThisTurn = undo.movesThisTurn;
    if (undo.lastMovedSq !== undefined) g.lastMovedSq = undo.lastMovedSq;
  },
  aiMoveCount: function(g) {
    return g.maxMovesPerTurn[g.turn] || 1;
  },
});

Large board with custom pieces

'use strict';
MCE.registerVariant('capablanca', {
  label: 'Capablanca',
  group: 'Large Boards',
  rows: 8, cols: 10,
  fen: 'rnabqkbcnr/pppppppppp/10/10/10/10/PPPPPPPPPP/RNABQKBCNR w KQkq - 0 1',
  promotionPieces: ['q', 'r', 'b', 'n', 'a', 'c'],
  title: 'Capablanca Chess',
  description: 'Adds Archbishop (a) and Chancellor (c) on a 10-wide board.',
  rule: 'Board: 10×8 · Win: Checkmate',
});

File Naming & Loading

Name your file using kebab-case matching the variant: js/variants/king-of-the-hill.js. Then add it to both:

The variant key (first argument to registerVariant) uses camelCase: 'kingOfTheHill'.

Testing

After creating your plugin:

Board Geometry

Moddable Chess supports rectangular boards from 4×8 up to 12×12. Hex-board, circular, and other non-rectangular geometries require a fundamentally different renderer and coordinate system.

For hex-board games, see Moddable Hexmaps.