This guide builds a complete game on top of MCE — not just a variant, but a standalone application with custom pieces, terrain, effects, capture interception, multi-step turns, and its own renderer. Dungeon Chess demonstrates using MCE as an embeddable game subsystem.
Dungeon Chess is a 4-player tactical battle on dungeon maps with water terrain, asymmetric factions (22 unique unit types), and special abilities. MCE provides all game logic, AI, and rendering — Dungeon Chess is a pure consumer that registers one variant and configures the engine.
Key engine features used:
registerPiece + buildUnitHandler — define 22 unit types with the unit template systemregisterVariant — register terrain predicates, all game hooksterrainSkip — water/void awareness for move generationmoveFilter — inject action moves (hex casting)beforeMove — capture interception (troll thick-skin)afterMove — board mutations (demonics explosion)turnLogic — multi-step turns (salamander retreat)positionKey — custom hash for pieceData-based gamescreateGameController — full interaction loop with callbackstilePainter / pieceProvider / afterRender — custom board renderingDungeon Chess loads MCE as a library and communicates through a single bridge module:
dungeon-chess/
├── lib/mce/ ← MCE library files (vendored)
│ ├── js/chess-engine.js
│ ├── js/chess-moves.js
│ ├── js/chess-play.js
│ ├── js/chess-variants.js
│ ├── js/chess-units.js
│ ├── js/chess-ai.js
│ ├── js/board-renderer.js
│ └── js/game-controller-core.js
├── js/
│ ├── mce-bridge.js ← All MCE integration (variant + unit registration)
│ ├── battle-draw.js ← Game controller setup, UI wiring
│ └── ... ← DC-specific UI, screens, state
└── index.html
The bridge module is the only file that touches the MCE namespace. Everything else is pure game UI.
Dungeon Chess uses MCE.createGame() with an object config to set up a large board with terrain and multi-player support:
var game = MCE.createGame({
rows: 10,
cols: 10,
terrain: buildTerrainArray(mapData),
players: ['w', 'b', 'r', 'g'],
variant: 'dungeon-chess',
ownershipMode: 'pieceData'
});
Key config properties:
terrain — flat array matching the board. Values like 'w' (water) or null (void/wall) affect move generationplayers — array of turn-order colour keys for multi-player gamesownershipMode: 'pieceData' — piece ownership is determined by g.pieceData[sq].owner rather than character caseThe terrain array is built from map section data — 26 modular dungeon tiles that combine into different board layouts.
Instead of writing genMoves/attacks by hand for every unit, use MCE.buildUnitHandler() with declarative move specs:
// Simple: a unit that moves like a rook but can't cross water
unitHandlers.dragon = MCE.buildUnitHandler({
move: 'rook:waterBlock'
});
// Divergent: moves like a king, attacks at range like a bishop
unitHandlers.archer = MCE.buildUnitHandler({
move: 'king:waterBlock',
attack: 'gapped:bishop:waterBlock'
});
// Knight-style jumps that respect water
unitHandlers.knight = MCE.buildUnitHandler({
move: 'knight:waterBlock'
});
// Cannon: needs a screen piece to capture over
unitHandlers.cannon = MCE.buildUnitHandler({
move: 'rook:waterBlock',
cannon: { dirs: 'rook' }
});
String specs are composed with colons: 'style:dirs:modifier'. Available tokens:
| Token | Meaning |
|---|---|
rook, bishop, queen, knight, king | Direction sets |
jump | Single-square jumps (like king) in the given directions |
gapped | Slide that must skip exactly one empty square before landing |
waterBlock | Movement blocked by water terrain |
For units with complex logic (e.g. the Orc's flexible 2-square move), provide a function instead:
unitHandlers.orc = {
genMoves(g, sq, side) {
var moves = [];
var [r, c] = MCE.rc(sq, g);
// Custom: up to 2 steps in any direction, can change direction
generateFlexibleMoves(g, sq, r, c, side, 2, moves);
return moves;
},
attacks(g, from, target) {
var [fr, fc] = MCE.rc(from, g);
var [tr, tc] = MCE.rc(target, g);
return Math.abs(tr - fr) <= 1 && Math.abs(tc - fc) <= 1;
}
};
All 22 unit types share a single MCE piece character ('x'). The registered handler dispatches to the correct unit based on pieceData:
MCE.registerPiece('x', {
genMoves(g, sq, side) {
var pd = g.pieceData[sq];
if (!pd) return [];
// Hexed units can't move
if (MCE.hasEffect(g, sq, 'hex')) return [];
var handler = unitHandlers[pd.key];
if (!handler) return [];
return handler.genMoves(g, sq, side);
},
attacks(g, from, target) {
var pd = g.pieceData[from];
if (!pd) return false;
if (MCE.hasEffect(g, from, 'hex')) return false;
var handler = unitHandlers[pd.key];
return handler ? handler.attacks(g, from, target) : false;
}
});
This pattern lets you have unlimited unit types without registering a separate piece character for each. The pieceData array (synced automatically by make/unmake) holds the unit identity:
// Place a unit on the board:
game.board[sq] = 'X'; // or 'x' — case doesn't matter with ownershipMode
game.pieceData[sq] = { key: 'dragon', owner: 'w', cost: 8, isKing: false };
The variant registration ties all hooks together and declares terrain behaviour:
MCE.registerVariant('dungeon-chess', {
label: 'Dungeon Chess',
terrainSkip: function(t) { return t === 'w' || t === 2; },
moveFilter: dcMoveFilter,
beforeMove: dcBeforeMove,
afterMove: dcAfterMove,
turnLogic: dcTurnLogic,
evaluate: dcEvaluate,
restoreState: dcRestoreState,
positionKey: dcPositionKey,
});
terrainSkip tells the engine which terrain values should block movement globally for this variant. Every call to genSlides, genJumps, genCannon, and genGappedSlides respects this predicate automatically — no per-call configuration needed.
Individual unit handlers can override with waterBlock: true in their move spec for finer control (e.g. a Wraith that phases through water).
The Shaman can cast a hex on any enemy unit instead of moving. This uses the action move pattern:
function dcMoveFilter(g, moves) {
var total = g.rows * g.cols;
for (var i = 0; i < total; i++) {
if (!g.board[i] || !g.pieceData[i]) continue;
var pd = g.pieceData[i];
if (pd.key !== 'shaman' || pd.owner !== g.turn || pd.hexUsed) continue;
// Add hex action for every enemy unit
for (var j = 0; j < total; j++) {
if (!g.board[j] || !g.pieceData[j]) continue;
var tpd = g.pieceData[j];
if (tpd.owner === g.turn || tpd.isKing) continue;
moves.push({ from: i, to: j, flag: 'action' });
}
}
return moves;
}
In beforeMove, detect the action and apply the effect:
function dcBeforeMove(g, move, undo) {
if (move.flag === 'action') {
var pd = g.pieceData[move.from];
if (pd && pd.key === 'shaman') {
undo._hexShamanSq = move.from;
pd.hexUsed = true;
MCE.addEffect(g, undo, { sq: move.to, type: 'hex', duration: 2 });
}
return;
}
// ... other beforeMove logic
}
The hex effect prevents the target from moving (checked in genMoves) and expires after 2 turns via MCE.tickEffects().
When a troll is captured, it doesn't die — it gets wounded and pushes the attacker back. The beforeMove hook intercepts the capture:
// In beforeMove, after the action check:
var targetPd = g.pieceData[move.to];
if (targetPd && targetPd.key === 'troll' && !targetPd.wounded) {
targetPd.wounded = true;
// Calculate push direction (attacker moves back)
var [fr, fc] = MCE.rc(move.from, g);
var [tr, tc] = MCE.rc(move.to, g);
var dr = tr - fr > 0 ? 1 : tr - fr < 0 ? -1 : 0;
var dc = tc - fc > 0 ? 1 : tc - fc < 0 ? -1 : 0;
var pushR = tr + dr, pushC = tc + dc;
if (MCE.onBoard(pushR, pushC, g)) {
var landSq = MCE.sq(pushR, pushC, g);
if (!g.board[landSq]) {
MCE.mutateBoard(g, undo, [{ sq: landSq, piece: 'X' }]);
g.pieceData[landSq] = targetPd;
g.board[move.to] = g.board[move.from];
g.pieceData[move.to] = g.pieceData[move.from];
g.board[move.from] = null;
g.pieceData[move.from] = null;
}
}
return { cancelCapture: true };
}
Returning { cancelCapture: true } tells the engine to skip its normal capture logic. The troll survives (wounded), and the attacker is repositioned manually via mutateBoard.
When a Demonics unit is captured, it explodes and destroys all adjacent enemy pieces:
function dcAfterMove(g, move, undo) {
if (!undo.captured) return;
var victimPd = undo.pieceDataTo;
if (!victimPd || victimPd.key !== 'demonics') return;
var [cr, cc] = MCE.rc(move.to, g);
for (var d = 0; d < MCE.QUEEN_DIRS.length; d++) {
var dir = MCE.QUEEN_DIRS[d];
var nr = cr + dir[0], nc = cc + dir[1];
if (!MCE.onBoard(nr, nc, g)) continue;
var adjSq = MCE.sq(nr, nc, g);
var adjPd = g.pieceData[adjSq];
if (adjPd && adjPd.owner !== victimPd.owner) {
MCE.mutateBoard(g, undo, [{ sq: adjSq, piece: null }]);
g.pieceData[adjSq] = null;
}
}
}
MCE.mutateBoard handles undo tracking automatically — the destroyed pieces reappear when the AI calls unmakeMove, but pieceData changes need manual restoration in restoreState.
After a Salamander captures, it must retreat to an adjacent empty square. This uses the pendingAction pattern inside turnLogic:
function dcTurnLogic(g, undo) {
if (undo.captured) {
var pd = g.pieceData[undo.to];
if (pd && pd.key === 'salamander') {
var retreatTargets = findAdjacentEmpty(g, undo.to);
if (retreatTargets.length > 0) {
g._pendingAction = {
from: undo.to,
filter: function(m) {
return retreatTargets.indexOf(m.to) >= 0;
}
};
return; // Don't advance turn yet
}
}
}
// Normal turn end
if (g.effects && g.effects.length > 0) MCE.tickEffects(g, undo);
MCE.advanceTurn(g);
}
When g._pendingAction is set, the engine restricts legal moves to only those matching the filter. The game controller shows the valid retreat squares. After the follow-up move, turnLogic is called again — this time without a pending action, so the turn advances normally.
Standard MCE position keys are based on the board character array. Since Dungeon Chess uses a single character ('X') for all units, the default key can't distinguish a Dragon from a Pawn. Provide a custom positionKey:
function dcPositionKey(g) {
var key = '';
var total = g.rows * g.cols;
for (var i = 0; i < total; i++) {
var pd = g.pieceData[i];
if (pd) key += pd.key.substring(0, 3) + pd.owner[0];
else key += '.';
}
return key + ' ' + g.turn;
}
This prevents transposition table collisions — the AI won't confuse positions where different unit types occupy the same squares.
Dungeon Chess uses all three renderer extension points to draw a themed dungeon board:
function dungeonTilePainter(svg, sq, r, c, tileSize, isLight, game) {
var terrain = game.terrain && game.terrain[sq];
var g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('width', tileSize);
rect.setAttribute('height', tileSize);
if (terrain === null) {
rect.setAttribute('fill', '#1a1a1a'); // Void/wall
} else if (terrain === 'w' || terrain === 2) {
rect.setAttribute('fill', '#1a4a6b'); // Water
} else {
rect.setAttribute('fill', isLight ? '#4a3d2e' : '#3d3225'); // Stone floor
}
g.appendChild(rect);
return g;
}
The tilePainter returns a <g> element. MCE automatically applies transform="translate(x,y)" to position it — you don't need to set x/y coordinates yourself.
function dungeonPieceProvider(game, sq, tileSize) {
var pd = game.pieceData && game.pieceData[sq];
if (!pd) return null;
var g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
var use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', '#sprite-' + pd.key);
use.setAttribute('width', tileSize * 0.85);
use.setAttribute('height', tileSize * 0.85);
use.setAttribute('fill', factionColor(pd.owner));
g.appendChild(use);
return g;
}
Custom pieces work seamlessly with MCE's animation system — all four modes (slide, arc, bounce, warp) position the returned element correctly during transitions.
function dungeonAfterRender(svg, game, tileSize, opts) {
if (!game.effects) return;
for (var i = 0; i < game.effects.length; i++) {
var e = game.effects[i];
if (e.type !== 'hex') continue;
var rc = MCE.rc(e.sq, game);
var r = opts.flipped ? game.rows - 1 - rc[0] : rc[0];
var c = opts.flipped ? game.cols - 1 - rc[1] : rc[1];
var ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
ring.setAttribute('cx', c * tileSize + tileSize / 2);
ring.setAttribute('cy', r * tileSize + tileSize / 2);
ring.setAttribute('r', tileSize * 0.4);
ring.setAttribute('fill', 'none');
ring.setAttribute('stroke', 'rgba(180,0,255,0.6)');
ring.setAttribute('stroke-width', '3');
svg.appendChild(ring);
}
}
Finally, MCE.createGameController() handles the full interaction loop — click handling, AI scheduling, undo/redo, and rendering:
var ctrl = MCE.createGameController(boardEl, game, {
players: { w: 'human', b: 'ai', r: 'ai', g: 'ai' },
aiDifficulty: 'medium',
workerPath: 'lib/mce/js/ai-worker.js',
renderOpts: {
size: 560,
animate: true,
animStyle: 'arc',
animDuration: 300,
animCaptureBurst: true,
tilePainter: dungeonTilePainter,
pieceProvider: dungeonPieceProvider,
afterRender: dungeonAfterRender,
suppressHighlights: false,
},
onMove: function(move, undo, captured) {
updateMoveLog(move);
playSound(captured ? 'capture' : 'move');
},
onSquareClick: function(sq, game, api) {
// Intercept clicks for hex targeting UI
if (hexTargetMode) {
castHex(sq, api);
return true; // Prevent default handling
}
return false;
},
onAnimateMove: function(move, game, execute) {
// Custom animation timing
showMovePreview(move);
setTimeout(execute, 100);
},
onGameEnd: function(result) {
showVictoryScreen(result);
},
onTurnChange: function(turn) {
highlightActivePlayer(turn);
},
onPendingAction: function(action, moves) {
showRetreatUI(action, moves);
},
});
The controller returns methods for external control:
undoBtn.onclick = function() { ctrl.undo(); };
ctrl.setDifficulty('hard');
ctrl.setFlipped(true);
ctrl.render(); // Force re-render after external state change
For atmosphere beyond the board edges (dungeon walls, torchlight), use surroundRenderer:
renderOpts: {
surroundRenderer: function(container, game, info) {
// info = { width, height, tileSize, rows, cols, flipped }
var canvas = document.createElement('canvas');
canvas.width = info.width + 120; // Extra space for walls
canvas.height = info.height + 120;
canvas.style.position = 'absolute';
canvas.style.left = '-60px';
canvas.style.top = '-60px';
container.style.position = 'relative';
drawDungeonWalls(canvas.getContext('2d'), info);
container.appendChild(canvas);
}
}
The surroundRenderer is called before the SVG board is created, so canvas elements or background divs render behind the board.
Hooks and APIs used:
| API / Hook | Purpose in Dungeon Chess |
|---|---|
registerPiece | Single dispatcher for 22 unit types via pieceData |
buildUnitHandler | Declarative move specs for simple units |
terrainSkip | Water/void blocks movement globally |
moveFilter | Inject hex action moves for Shaman |
beforeMove | Troll thick-skin capture interception |
afterMove | Demonics explosion via mutateBoard |
turnLogic | Salamander forced retreat (pendingAction) |
positionKey | Hash pieceData keys for AI accuracy |
createGameController | Full interaction loop with AI, undo, animation |
tilePainter | Terrain-aware dungeon floor tiles |
pieceProvider | Faction-coloured SVG sprites |
afterRender | Hex effect overlays |
surroundRenderer | Dungeon wall atmosphere |
onSquareClick | Intercept clicks for hex targeting UI |
onAnimateMove | Custom animation timing |
The complete bridge module is approximately 570 lines. MCE provides 100% of game logic, AI, rendering, animation, and undo — Dungeon Chess adds only unit definitions, variant hooks, and UI.