Every variant is a single JavaScript file that calls MCE.registerVariant(). No core engine code needs to be modified.
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.
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.
Every plugin must provide these required properties:
| Property | Type | Description |
|---|---|---|
label | string | Display name in the variant picker |
group | string | Picker group (Classic, Tactical, Alternate Rules, Large Boards) |
rows | number | Board height (5–12) |
cols | number | Board width (5–12) |
fen | string|null | Starting FEN, or null for standard position |
title | string | Full name shown in the description panel |
description | string | Rules explanation shown to the player |
rule | string | One-line summary (e.g. "Board: 8×8 · Win: Checkmate") |
Boolean or value flags that modify engine behaviour without custom code:
| Flag | Type | Effect |
|---|---|---|
noCastling | boolean | Disables castling |
noEnPassant | boolean | Disables en passant captures |
noPromotion | boolean | Disables pawn promotion |
noCheck | boolean | Skips check validation (king is capturable) |
torpedo | boolean | Pawns can double-push from any rank |
checkThreshold | number | Number of checks to win (use with winCondition) |
stalemateMeaning | string | 'win' makes stalemate a win for the stalemated side |
promotionPieces | array | Override promotion choices (e.g. ['q','r','b','n','a','c']) |
royalPiece | string | Which piece type is royal (default: 'k') |
pieceRoles | object | Swap piece roles (e.g. { n: 'k', k: 'n' } for Knightmate) |
pawnDirection | function | (side) => direction — override pawn march direction |
pawnStartRow | function | (side) => row — override double-push start row |
maxMovesPerTurn | object | { w: 2, b: 1 } — moves per turn per side |
progressiveMove | number | Initial move count for progressive variants |
promotionRank | function | (side) => row — override which row triggers pawn promotion (default: row 0 for white, last row for black) |
pawnMoveStyle | string | 'standard' (default) or 'berolina' — Berolina pawns move diagonally and capture straight |
divergentPieces | object | Map piece types to separate move/capture patterns. Each entry: { move, moveStyle, capture, captureStyle } |
wrapFiles | boolean | Files wrap (a-file connects to h-file, cylinder geometry) |
wrapRanks | boolean | Ranks wrap (rank 1 connects to rank 8, toroidal with wrapFiles) |
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) { /* ... */ },
});
MCE.registerVariant('makruk', {
// ...config...
promotionRank: function(side) { return side === MCE.WHITE ? 2 : 5; },
promotionPieces: ['m'], // promote to Met only
});
MCE.registerVariant('berolinaChess', {
// ...config...
pawnMoveStyle: 'berolina', // pawns move diagonal, capture straight
});
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' },
},
});
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,
});
Hooks let plugins inject custom logic at specific points in the game loop. All hooks are optional.
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;
},
});
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;
}
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;
}
}
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;
}
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;
}
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)];
}
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;
}
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;
}
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;
}
Tells the AI how many sequential moves to make per turn. Default is 1.
aiMoveCount: function(g) {
return g.maxMovesPerTurn[g.turn] || 1;
}
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.
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.
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;
},
});
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:
genMoves(g, from, side) — return all pseudo-legal moves for the piece at fromattacks(g, from, target) — return true if the piece at from attacks the target squareUse the piece character in your FEN string (lowercase for black, uppercase for white): '4M3' places a white Maharaja on e1.
'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',
});
'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;
},
});
'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;
},
});
'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',
});
Name your file using kebab-case matching the variant: js/variants/king-of-the-hill.js. Then add it to both:
js/variants/index.js — the auto-loader plugin listplay/index.html — direct <script> tag with version query stringThe variant key (first argument to registerVariant) uses camelCase: 'kingOfTheHill'.
After creating your plugin:
/play/ and select your variant from the pickerturnLogic, verify multi-move sequences and that undo restores stateModdable 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.