Docs / Guides / MCE as a Game Subsystem

Building Dungeon Chess

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.

What We Are Building

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 system
  • registerVariant — register terrain predicates, all game hooks
  • terrainSkip — water/void awareness for move generation
  • moveFilter — 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 games
  • createGameController — full interaction loop with callbacks
  • tilePainter / pieceProvider / afterRender — custom board rendering

Architecture

Dungeon 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.

Step 1: Create the Game with Terrain

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 generation
  • players — array of turn-order colour keys for multi-player games
  • ownershipMode: 'pieceData' — piece ownership is determined by g.pieceData[sq].owner rather than character case

The terrain array is built from map section data — 26 modular dungeon tiles that combine into different board layouts.

Step 2: Register Units with the Template System

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:

TokenMeaning
rook, bishop, queen, knight, kingDirection sets
jumpSingle-square jumps (like king) in the given directions
gappedSlide that must skip exactly one empty square before landing
waterBlockMovement 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;
  }
};

Step 3: Register the Shared Piece Handler

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

Step 4: Register the Variant with Terrain Predicates

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).

Step 5: Action Moves — Hex Casting

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().

Step 6: Capture Interception — Troll Thick-Skin

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.

Step 7: Board Mutations — Demonics Explosion

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.

Step 8: Multi-Step Turns — Salamander Retreat

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.

Step 9: Custom Position Key

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.

Step 10: Custom Rendering

Dungeon Chess uses all three renderer extension points to draw a themed dungeon board:

tilePainter — Terrain Tiles

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.

pieceProvider — Faction-Coloured Units

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.

afterRender — Effect Overlays

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

Step 11: Wire Up the Game Controller

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

Step 12: Surround Renderer

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.

Summary

Hooks and APIs used:

API / HookPurpose in Dungeon Chess
registerPieceSingle dispatcher for 22 unit types via pieceData
buildUnitHandlerDeclarative move specs for simple units
terrainSkipWater/void blocks movement globally
moveFilterInject hex action moves for Shaman
beforeMoveTroll thick-skin capture interception
afterMoveDemonics explosion via mutateBoard
turnLogicSalamander forced retreat (pendingAction)
positionKeyHash pieceData keys for AI accuracy
createGameControllerFull interaction loop with AI, undo, animation
tilePainterTerrain-aware dungeon floor tiles
pieceProviderFaction-coloured SVG sprites
afterRenderHex effect overlays
surroundRendererDungeon wall atmosphere
onSquareClickIntercept clicks for hex targeting UI
onAnimateMoveCustom 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.

Back to all guides | API Reference