Examples
Checkers
Constraint-driven game logic with multi-module composition, AI integration, and time-travel debugging.
Play it
2-player and vs Computer modes work in the embed. The vs Claude mode requires an API key and dev server proxy.
Embed it
Register the custom element, then use it like any HTML tag. No iframe – the game renders directly in your page.
<script>
class DirectiveCheckers extends HTMLElement {
connectedCallback() {
fetch('/examples/checkers/index.html')
.then(r => r.text())
.then(html => {
const doc = new DOMParser().parseFromString(html, 'text/html');
// Inject scoped styles
const style = document.createElement('style');
const rawCss = doc.querySelector('style')?.textContent || '';
style.textContent = rawCss
.replace(/^(\s*)\*\s*\{/m, '$1directive-checkers, directive-checkers * {')
.replace(/^(\s*)body\s*\{/m, '$1directive-checkers {')
.replace(/^(\s*)h1\s*\{/m, '$1directive-checkers h1 {')
.replace(/^(\s*)button(\s*[{:.])/gm, '$1directive-checkers button$2');
document.head.appendChild(style);
// Inject body content
this.innerHTML = doc.body.innerHTML;
// Load game JS
const src = doc.querySelector('script[src]')?.getAttribute('src');
if (src) {
const s = document.createElement('script');
s.type = 'module';
s.src = src;
document.head.appendChild(s);
}
});
}
}
customElements.define('directive-checkers', DirectiveCheckers);
</script>
<directive-checkers></directive-checkers>
How it works
The game is built as a multi-module Directive system with two modules: game (board logic) and chat (AI conversation). Pure game rules live in a separate rules.ts file with no Directive dependency.
- Facts – Board state, current player, selection, game mode
- Derivations – Valid moves, highlight squares, score (auto-tracked, no manual deps)
- Events –
clickSquaresets selection and target - Constraints –
executeMovefires when a valid selection+target exists,kingPiecewhen on back row,gameOverwhen no moves remain - Resolvers – Apply the move, handle multi-jump chains, switch turns
- Effects – Log moves and game results
Summary
What: A two-player checkers game with optional AI opponent, multi-jump chains, king promotion, and move validation.
How: Built as a multi-module Directive system. The game module tracks board state, selection, and turns. Derivations compute valid moves and highlights. Constraints fire when a valid move is selected, a piece reaches the back row, or no moves remain. Pure game rules live in rules.ts with no Directive dependency.
Why it works: Checkers has complex cascading logic (move → capture → multi-jump → king → game over). Directive’s constraint priorities handle the cascade automatically – the executeMove constraint fires first, then kingPiece, then gameOver, each reacting to the state the previous one left behind.
Source code
/**
* Checkers - Directive Module
*
* Constraint-driven checkers game. Pure game logic lives in rules.ts;
* this file coordinates it through Directive's fact→derivation→constraint→resolver flow.
*/
import { createModule, t, type ModuleSchema } from "@directive-run/core";
import {
type Board,
type Player,
type Move,
createInitialBoard,
getAllValidMoves,
getJumpMoves,
getValidMovesForPiece,
playerHasJumps,
applyMove,
shouldKing,
promotePiece,
countPieces,
opponent,
hasNoValidMoves,
pickAiMove,
pickAiJumpFrom,
} from "./rules.js";
// ============================================================================
// Schema
// ============================================================================
export const checkersSchema = {
facts: {
board: t.object<Board>(),
currentPlayer: t.object<Player>(),
selectedIndex: t.object<number | null>(),
targetIndex: t.object<number | null>(),
mustContinueFrom: t.object<number | null>(),
message: t.string(),
moveCount: t.number(),
capturedCount: t.object<{ red: number; black: number }>(),
gameOver: t.boolean(),
winner: t.object<Player | null>(),
gameMode: t.object<"2player" | "computer" | "ai">(),
aiPlayer: t.object<Player>(),
},
derivations: {
validMoves: t.object<Move[]>(),
jumpRequired: t.boolean(),
highlightSquares: t.object<number[]>(),
selectableSquares: t.object<number[]>(),
redCount: t.number(),
blackCount: t.number(),
score: t.string(),
},
events: {
clickSquare: { index: t.number() },
newGame: {},
setGameMode: { mode: t.object<"2player" | "computer" | "ai">() },
aiMove: {},
claudeMove: { from: t.number(), to: t.number() },
},
requirements: {
EXECUTE_MOVE: {
from: t.number(),
to: t.number(),
captured: t.object<number | null>(),
},
KING_PIECE: { index: t.number() },
END_GAME: { winner: t.object<Player>(), reason: t.string() },
},
} satisfies ModuleSchema;
// ============================================================================
// Module
// ============================================================================
export const checkersGame = createModule("checkers", {
schema: checkersSchema,
init: (facts) => {
facts.board = createInitialBoard();
facts.currentPlayer = "red";
facts.selectedIndex = null;
facts.targetIndex = null;
facts.mustContinueFrom = null;
facts.message = "Red's turn. Select a piece to move.";
facts.moveCount = 0;
facts.capturedCount = { red: 0, black: 0 };
facts.gameOver = false;
facts.winner = null;
facts.gameMode = "2player";
facts.aiPlayer = "black";
},
// ============================================================================
// Derivations
// ============================================================================
derive: {
validMoves: (facts) => {
const sel = facts.selectedIndex as number | null;
if (sel === null) return [];
return getValidMovesForPiece(facts.board, sel);
},
jumpRequired: (facts) => {
return playerHasJumps(facts.board, facts.currentPlayer);
},
highlightSquares: (facts) => {
const sel = facts.selectedIndex as number | null;
if (sel === null) return [];
const moves = getValidMovesForPiece(facts.board, sel);
return moves.map((m) => m.to);
},
selectableSquares: (facts) => {
if (facts.gameOver) return [];
if (facts.gameMode !== "2player" && facts.currentPlayer === facts.aiPlayer) return [];
const cont = facts.mustContinueFrom as number | null;
if (cont !== null) return [cont];
const allMoves = getAllValidMoves(facts.board, facts.currentPlayer);
const fromIndices = new Set(allMoves.map((m) => m.from));
return [...fromIndices];
},
redCount: (facts) => countPieces(facts.board).red,
blackCount: (facts) => countPieces(facts.board).black,
score: (facts, derive) => {
// Touch facts.board for dependency tracking (derive reads alone aren't tracked)
facts.board;
return `Red ${derive.redCount} — Black ${derive.blackCount}`;
},
},
// ============================================================================
// Events
// ============================================================================
events: {
clickSquare: (facts, { index }) => {
if (facts.gameOver) return;
// Ignore clicks during AI's turn in computer/ai mode
if (facts.gameMode !== "2player" && facts.currentPlayer === facts.aiPlayer) return;
const board = facts.board as Board;
const player = facts.currentPlayer as Player;
const selected = facts.selectedIndex as number | null;
const cont = facts.mustContinueFrom as number | null;
const piece = board[index];
// During multi-jump: only accept valid jump targets for the continuing piece
if (cont !== null) {
const jumps = getJumpMoves(board, cont);
const validTarget = jumps.find((m) => m.to === index);
if (validTarget) {
facts.selectedIndex = cont;
facts.targetIndex = index;
} else if (index !== cont) {
facts.message = "You must continue jumping with the same piece.";
}
return;
}
// Clicking own piece → select it
if (piece && piece.player === player) {
// Enforce forced capture: if jumps exist globally, only pieces with jumps are selectable
const hasJumps = playerHasJumps(board, player);
if (hasJumps && getJumpMoves(board, index).length === 0) {
facts.message = "You must make a jump! Select a piece that can capture.";
return;
}
facts.selectedIndex = index;
facts.targetIndex = null;
facts.message = `Selected. Choose a destination.`;
return;
}
// Piece is selected + clicking a valid move target → set targetIndex
if (selected !== null) {
const moves = getValidMovesForPiece(board, selected);
const move = moves.find((m) => m.to === index);
if (move) {
facts.targetIndex = index;
return;
}
}
// Clicking empty or opponent square with nothing selected
facts.selectedIndex = null;
facts.targetIndex = null;
if (selected !== null) {
facts.message = "Invalid move. Select one of your pieces.";
}
},
newGame: (facts) => {
const mode = facts.gameMode;
facts.board = createInitialBoard();
facts.currentPlayer = "red";
facts.selectedIndex = null;
facts.targetIndex = null;
facts.mustContinueFrom = null;
facts.message = "Red's turn. Select a piece to move.";
facts.moveCount = 0;
facts.capturedCount = { red: 0, black: 0 };
facts.gameOver = false;
facts.winner = null;
facts.gameMode = mode;
facts.aiPlayer = "black";
},
setGameMode: (facts, { mode }) => {
facts.board = createInitialBoard();
facts.currentPlayer = "red";
facts.selectedIndex = null;
facts.targetIndex = null;
facts.mustContinueFrom = null;
facts.message = "Red's turn. Select a piece to move.";
facts.moveCount = 0;
facts.capturedCount = { red: 0, black: 0 };
facts.gameOver = false;
facts.winner = null;
facts.gameMode = mode;
facts.aiPlayer = "black";
},
aiMove: (facts) => {
if (facts.gameOver) return;
if (facts.gameMode !== "computer") return;
if (facts.currentPlayer !== facts.aiPlayer) return;
const board = facts.board as Board;
const player = facts.currentPlayer as Player;
const cont = facts.mustContinueFrom as number | null;
if (cont !== null) {
// Multi-jump continuation: pick best jump from the continuing piece
const jump = pickAiJumpFrom(board, cont, player);
if (jump) {
facts.selectedIndex = cont;
facts.targetIndex = jump.to;
}
} else {
// Normal turn: pick best move
const move = pickAiMove(board, player);
if (move) {
facts.selectedIndex = move.from;
facts.targetIndex = move.to;
}
}
},
claudeMove: (facts, { from, to }) => {
if (facts.gameOver) return;
if (facts.gameMode !== "ai") return;
if (facts.currentPlayer !== facts.aiPlayer) return;
facts.selectedIndex = from;
facts.targetIndex = to;
},
},
// ============================================================================
// Constraints
// ============================================================================
constraints: {
executeMove: {
priority: 100,
when: (facts) => {
if (facts.gameOver) return false;
const sel = facts.selectedIndex as number | null;
const target = facts.targetIndex as number | null;
if (sel === null || target === null) return false;
const moves = getValidMovesForPiece(facts.board, sel);
return moves.some((m) => m.to === target);
},
require: (facts) => {
const sel = facts.selectedIndex as number;
const target = facts.targetIndex as number;
const moves = getValidMovesForPiece(facts.board, sel);
const move = moves.find((m) => m.to === target)!;
return {
type: "EXECUTE_MOVE",
from: move.from,
to: move.to,
captured: move.captured,
};
},
},
kingPiece: {
priority: 80,
when: (facts) => {
if (facts.gameOver) return false;
// Check the entire board for pieces that should be kinged
const board = facts.board as Board;
for (let i = 0; i < 64; i++) {
if (shouldKing(board, i)) return true;
}
return false;
},
require: (facts) => {
const board = facts.board as Board;
for (let i = 0; i < 64; i++) {
if (shouldKing(board, i)) {
return { type: "KING_PIECE", index: i };
}
}
// Shouldn't reach here since `when` already verified
return { type: "KING_PIECE", index: 0 };
},
},
gameOver: {
priority: 50,
when: (facts) => {
if (facts.gameOver) return false;
// Only check after a turn is fully complete (no pending multi-jump)
if (facts.mustContinueFrom !== null) return false;
return hasNoValidMoves(facts.board, facts.currentPlayer);
},
require: (facts) => ({
type: "END_GAME",
winner: opponent(facts.currentPlayer),
reason: `${opponent(facts.currentPlayer)} wins! ${facts.currentPlayer} has no valid moves.`,
}),
},
},
// ============================================================================
// Resolvers
// ============================================================================
resolvers: {
executeMove: {
requirement: "EXECUTE_MOVE",
resolve: async (req, context) => {
const board = context.facts.board as Board;
const move: Move = { from: req.from, to: req.to, captured: req.captured };
// Apply the move
let newBoard = applyMove(board, move);
// Track captures
if (req.captured !== null) {
const capturedPiece = board[req.captured];
if (capturedPiece) {
const counts = { ...(context.facts.capturedCount as { red: number; black: number }) };
counts[capturedPiece.player]++;
context.facts.capturedCount = counts;
}
}
context.facts.moveCount++;
// Check kinging
if (shouldKing(newBoard, req.to)) {
// Kinging ends the turn (standard American checkers)
newBoard = promotePiece(newBoard, req.to);
context.facts.board = newBoard;
context.facts.selectedIndex = null;
context.facts.targetIndex = null;
context.facts.mustContinueFrom = null;
const next = opponent(context.facts.currentPlayer);
context.facts.currentPlayer = next;
context.facts.message = `Kinged! ${next}'s turn.`;
return;
}
// Check for multi-jump continuation
if (req.captured !== null) {
const moreJumps = getJumpMoves(newBoard, req.to);
if (moreJumps.length > 0) {
context.facts.board = newBoard;
context.facts.selectedIndex = req.to;
context.facts.targetIndex = null;
context.facts.mustContinueFrom = req.to;
context.facts.message = "Jump again! You must continue capturing.";
return;
}
}
// Normal turn end: switch players
context.facts.board = newBoard;
context.facts.selectedIndex = null;
context.facts.targetIndex = null;
context.facts.mustContinueFrom = null;
const next = opponent(context.facts.currentPlayer);
context.facts.currentPlayer = next;
context.facts.message = `${next}'s turn.`;
},
},
kingPiece: {
requirement: "KING_PIECE",
resolve: async (req, context) => {
context.facts.board = promotePiece(context.facts.board, req.index);
},
},
endGame: {
requirement: "END_GAME",
resolve: async (req, context) => {
context.facts.gameOver = true;
context.facts.winner = req.winner;
context.facts.selectedIndex = null;
context.facts.targetIndex = null;
context.facts.mustContinueFrom = null;
context.facts.message = req.reason;
},
},
},
// ============================================================================
// Effects
// ============================================================================
effects: {
moveLog: {
deps: ["moveCount"],
run: (facts) => {
if (facts.moveCount > 0) {
const { red, black } = countPieces(facts.board);
console.log(`[Checkers] Move ${facts.moveCount} | Red: ${red}, Black: ${black}`);
}
},
},
gameOverLog: {
deps: ["gameOver"],
run: (facts) => {
if (facts.gameOver) {
const { red, black } = countPieces(facts.board);
console.log(
`[Checkers] Game Over! Winner: ${facts.winner} | ` +
`Moves: ${facts.moveCount} | Red: ${red}, Black: ${black}`
);
}
},
},
},
});

