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.
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}`
);
}
},
},
},
});

