Directive is community-sustained.
Support the projectExamples
Sudoku
Constraint satisfaction, powered by Directive. The game rules ARE the constraints.
Play it
Use arrow keys to navigate, 1–9 to input, Backspace to clear, N for notes, H for hint. Ctrl+Z / Ctrl+Shift+Z for undo/redo.
How it works
Sudoku is literally a constraint satisfaction problem: no duplicates in rows, columns, or 3×3 boxes. The game rules map 1:1 to Directive’s constraint–resolver flow.
- Facts – Grid state, solution, givens, timer, selection, notes, difficulty
- Derivations – Conflicts, progress, timer display, same-number highlighting, candidates (auto-tracked, no manual deps)
- Events –
selectCell,inputNumber,toggleNote,requestHint,tick,newGame - Constraints –
timerExpired(priority 200),detectConflict(100),puzzleSolved(90),hintAvailable(70) – evaluated by priority after every fact change - Resolvers – Handle game won/lost, increment error count, reveal hints
- Effects – Timer warnings at 60s and 30s, game result logging
The constraint cascade is the key insight: when a player types “5”, the grid fact updates, derivations recompute (conflicts, progress, isSolved), then constraints evaluate by priority – detecting conflicts, checking for a win, or firing a hint.
Summary
What: A fully playable Sudoku puzzle with multiple difficulties, notes, hints, undo/redo, and a countdown timer.
How: The game is a single Directive module with 14 facts tracking grid/selection/timer state. Derivations auto-compute conflicts, progress, and candidates. Four prioritized constraints cascade on every input to detect conflicts, check for a win, expire the timer, or reveal hints. Resolvers handle the outcomes. Effects fire timer warnings.
Why it works: Sudoku is a constraint satisfaction problem – Directive’s constraint–resolver flow maps 1:1 to the game rules. No imperative state machine needed; declare what must be true and Directive handles the rest.
Source code
/**
* Sudoku – Directive Module
*
* Constraint-driven Sudoku game. Sudoku IS a constraint satisfaction problem:
* no duplicates in rows, columns, or 3x3 boxes. The game rules map directly
* to Directive's constraint→resolver flow.
*
* Also demonstrates temporal constraints (countdown timer) and runtime
* reconfiguration (difficulty modes) – patterns not shown in checkers.
*
* Pure Sudoku logic lives in rules.ts; puzzle generation in generator.ts.
*/
import { createModule, t, type ModuleSchema } from "@directive-run/core";
import {
type Grid,
type Difficulty,
type Conflict,
TIMER_DURATIONS,
MAX_HINTS,
TIMER_WARNING_THRESHOLD,
TIMER_CRITICAL_THRESHOLD,
TIMER_EFFECT_WARNING,
TIMER_EFFECT_CRITICAL,
findConflicts,
isBoardComplete,
toRowCol,
getPeers,
getCandidates,
createEmptyNotes,
} from "./rules.js";
import { generatePuzzle } from "./generator.js";
// ============================================================================
// Schema
// ============================================================================
export const sudokuSchema = {
facts: {
grid: t.object<Grid>(),
solution: t.object<Grid>(),
givens: t.object<Set<number>>(),
selectedIndex: t.object<number | null>(),
difficulty: t.object<Difficulty>(),
timerRemaining: t.number(),
timerRunning: t.boolean(),
gameOver: t.boolean(),
won: t.boolean(),
message: t.string(),
notesMode: t.boolean(),
notes: t.object<Set<number>[]>(),
hintsUsed: t.number(),
errorsCount: t.number(),
hintRequested: t.boolean(),
},
derivations: {
conflicts: t.object<Conflict[]>(),
conflictIndices: t.object<Set<number>>(),
hasConflicts: t.boolean(),
filledCount: t.number(),
progress: t.number(),
isComplete: t.boolean(),
isSolved: t.boolean(),
selectedPeers: t.object<number[]>(),
highlightValue: t.number(),
sameValueIndices: t.object<Set<number>>(),
candidates: t.object<number[]>(),
timerDisplay: t.string(),
timerUrgency: t.object<"normal" | "warning" | "critical">(),
},
events: {
newGame: { difficulty: t.object<Difficulty>() },
selectCell: { index: t.number() },
inputNumber: { value: t.number() },
toggleNote: { value: t.number() },
toggleNotesMode: {},
requestHint: {},
tick: {},
},
requirements: {
SHOW_CONFLICT: {
index: t.number(),
value: t.number(),
row: t.number(),
col: t.number(),
},
GAME_WON: {
timeLeft: t.number(),
hintsUsed: t.number(),
errors: t.number(),
},
GAME_OVER: {
reason: t.string(),
},
REVEAL_HINT: {
index: t.number(),
value: t.number(),
},
},
} satisfies ModuleSchema;
// ============================================================================
// Module
// ============================================================================
export const sudokuGame = createModule("sudoku", {
schema: sudokuSchema,
snapshotEvents: ["inputNumber", "toggleNote", "requestHint", "newGame"],
init: (facts) => {
const { puzzle, solution } = generatePuzzle("easy");
const givens = new Set<number>();
for (let i = 0; i < 81; i++) {
if (puzzle[i] !== 0) {
givens.add(i);
}
}
facts.grid = puzzle;
facts.solution = solution;
facts.givens = givens;
facts.selectedIndex = null;
facts.difficulty = "easy";
facts.timerRemaining = TIMER_DURATIONS.easy;
facts.timerRunning = true;
facts.gameOver = false;
facts.won = false;
facts.message = "Fill in the grid. No duplicates in rows, columns, or boxes.";
facts.notesMode = false;
facts.notes = createEmptyNotes();
facts.hintsUsed = 0;
facts.errorsCount = 0;
facts.hintRequested = false;
},
// ============================================================================
// Derivations
// ============================================================================
derive: {
conflicts: (facts) => {
return findConflicts(facts.grid as Grid);
},
conflictIndices: (facts, derive) => {
const indices = new Set<number>();
const givens = facts.givens as Set<number>;
for (const c of derive.conflicts as Conflict[]) {
// Only highlight player-placed cells, not givens
if (!givens.has(c.index)) {
indices.add(c.index);
}
}
return indices;
},
hasConflicts: (_facts, derive) => {
return (derive.conflicts as Conflict[]).length > 0;
},
filledCount: (facts) => {
let count = 0;
const grid = facts.grid as Grid;
for (let i = 0; i < 81; i++) {
if (grid[i] !== 0) {
count++;
}
}
return count;
},
progress: (_facts, derive) => {
return Math.round(((derive.filledCount as number) / 81) * 100);
},
isComplete: (facts) => {
return isBoardComplete(facts.grid as Grid);
},
isSolved: (_facts, derive) => {
return (derive.isComplete as boolean) && !(derive.hasConflicts as boolean);
},
selectedPeers: (facts) => {
const sel = facts.selectedIndex as number | null;
if (sel === null) {
return [];
}
return getPeers(sel);
},
highlightValue: (facts) => {
const sel = facts.selectedIndex as number | null;
if (sel === null) {
return 0;
}
return (facts.grid as Grid)[sel];
},
sameValueIndices: (facts, derive) => {
const val = derive.highlightValue as number;
if (val === 0) {
return new Set<number>();
}
const indices = new Set<number>();
const grid = facts.grid as Grid;
for (let i = 0; i < 81; i++) {
if (grid[i] === val) {
indices.add(i);
}
}
return indices;
},
candidates: (facts) => {
const sel = facts.selectedIndex as number | null;
if (sel === null) {
return [];
}
return getCandidates(facts.grid as Grid, sel);
},
timerDisplay: (facts) => {
const remaining = facts.timerRemaining as number;
const mins = Math.max(0, Math.floor(remaining / 60));
const secs = Math.max(0, remaining % 60);
return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
},
timerUrgency: (facts) => {
const remaining = facts.timerRemaining as number;
if (remaining <= TIMER_CRITICAL_THRESHOLD) {
return "critical";
}
if (remaining <= TIMER_WARNING_THRESHOLD) {
return "warning";
}
return "normal";
},
},
// ============================================================================
// Events
// ============================================================================
events: {
newGame: (facts, { difficulty }) => {
const { puzzle, solution } = generatePuzzle(difficulty);
const givens = new Set<number>();
for (let i = 0; i < 81; i++) {
if (puzzle[i] !== 0) {
givens.add(i);
}
}
facts.grid = puzzle;
facts.solution = solution;
facts.givens = givens;
facts.selectedIndex = null;
facts.difficulty = difficulty;
facts.timerRemaining = TIMER_DURATIONS[difficulty];
facts.timerRunning = true;
facts.gameOver = false;
facts.won = false;
facts.message = "Fill in the grid. No duplicates in rows, columns, or boxes.";
facts.notesMode = false;
facts.notes = createEmptyNotes();
facts.hintsUsed = 0;
facts.errorsCount = 0;
facts.hintRequested = false;
},
selectCell: (facts, { index }) => {
if (facts.gameOver) {
return;
}
facts.selectedIndex = index;
},
inputNumber: (facts, { value }) => {
if (facts.gameOver) {
return;
}
const sel = facts.selectedIndex as number | null;
if (sel === null) {
return;
}
const givens = facts.givens as Set<number>;
if (givens.has(sel)) {
facts.message = "That cell is locked.";
return;
}
if (facts.notesMode && value !== 0) {
// In notes mode, toggle the pencil mark instead
const notes = [...(facts.notes as Set<number>[])];
notes[sel] = new Set(notes[sel]);
if (notes[sel].has(value)) {
notes[sel].delete(value);
} else {
notes[sel].add(value);
}
facts.notes = notes;
facts.message = "";
return;
}
// Place or clear a number
const grid = [...(facts.grid as Grid)];
grid[sel] = value;
facts.grid = grid;
// Clear notes for this cell when placing a number
if (value !== 0) {
const notes = [...(facts.notes as Set<number>[])];
notes[sel] = new Set();
// Also clear this value from peer notes
for (const peer of getPeers(sel)) {
if (notes[peer].has(value)) {
notes[peer] = new Set(notes[peer]);
notes[peer].delete(value);
}
}
facts.notes = notes;
}
facts.message = "";
},
toggleNote: (facts, { value }) => {
if (facts.gameOver) {
return;
}
const sel = facts.selectedIndex as number | null;
if (sel === null) {
return;
}
const givens = facts.givens as Set<number>;
if (givens.has(sel)) {
return;
}
// Only allow notes on empty cells
if ((facts.grid as Grid)[sel] !== 0) {
return;
}
const notes = [...(facts.notes as Set<number>[])];
notes[sel] = new Set(notes[sel]);
if (notes[sel].has(value)) {
notes[sel].delete(value);
} else {
notes[sel].add(value);
}
facts.notes = notes;
},
toggleNotesMode: (facts) => {
facts.notesMode = !(facts.notesMode as boolean);
},
requestHint: (facts) => {
if (facts.gameOver) {
return;
}
if (facts.hintsUsed >= MAX_HINTS) {
facts.message = "No hints remaining.";
return;
}
const sel = facts.selectedIndex as number | null;
if (sel === null) {
facts.message = "Select a cell first.";
return;
}
const givens = facts.givens as Set<number>;
if (givens.has(sel)) {
facts.message = "That cell is already filled.";
return;
}
if ((facts.grid as Grid)[sel] !== 0) {
facts.message = "Clear the cell first, or select an empty cell.";
return;
}
// Signal the hintAvailable constraint to fire
facts.hintRequested = true;
},
tick: (facts) => {
if (!facts.timerRunning || facts.gameOver) {
return;
}
facts.timerRemaining = Math.max(0, (facts.timerRemaining as number) - 1);
},
},
// ============================================================================
// Constraints – The Showcase
// ============================================================================
constraints: {
// Highest priority: timer expiry ends the game immediately
timerExpired: {
priority: 200,
when: (facts) => {
if (facts.gameOver) {
return false;
}
return (facts.timerRemaining as number) <= 0;
},
require: () => ({
type: "GAME_OVER",
reason: "Time's up!",
}),
},
// Detect conflicts on player-placed cells
detectConflict: {
priority: 100,
when: (facts) => {
if (facts.gameOver) {
return false;
}
const conflicts = findConflicts(facts.grid as Grid);
const givens = facts.givens as Set<number>;
return conflicts.some((c) => !givens.has(c.index));
},
require: (facts) => {
const conflicts = findConflicts(facts.grid as Grid);
const givens = facts.givens as Set<number>;
const playerConflict = conflicts.find((c) => !givens.has(c.index));
const idx = playerConflict?.index ?? 0;
const { row, col } = toRowCol(idx);
return {
type: "SHOW_CONFLICT",
index: idx,
value: playerConflict?.value ?? 0,
row: row + 1,
col: col + 1,
};
},
},
// Puzzle solved: all cells filled with no conflicts
puzzleSolved: {
priority: 90,
when: (facts) => {
if (facts.gameOver) {
return false;
}
return isBoardComplete(facts.grid as Grid) && findConflicts(facts.grid as Grid).length === 0;
},
require: (facts) => ({
type: "GAME_WON",
timeLeft: facts.timerRemaining as number,
hintsUsed: facts.hintsUsed as number,
errors: facts.errorsCount as number,
}),
},
// Hint available: player requested a hint on an empty cell
hintAvailable: {
priority: 70,
when: (facts) => {
if (facts.gameOver) {
return false;
}
if (!facts.hintRequested) {
return false;
}
const sel = facts.selectedIndex as number | null;
if (sel === null) {
return false;
}
return (facts.grid as Grid)[sel] === 0;
},
require: (facts) => {
const sel = facts.selectedIndex as number;
const solution = facts.solution as Grid;
return {
type: "REVEAL_HINT",
index: sel,
value: solution[sel],
};
},
},
},
// ============================================================================
// Resolvers
// ============================================================================
resolvers: {
showConflict: {
requirement: "SHOW_CONFLICT",
resolve: async (req, context) => {
context.facts.errorsCount = (context.facts.errorsCount as number) + 1;
context.facts.message = `Conflict at row ${req.row}, column ${req.col} – duplicate ${req.value}.`;
},
},
gameWon: {
requirement: "GAME_WON",
resolve: async (req, context) => {
context.facts.timerRunning = false;
context.facts.gameOver = true;
context.facts.won = true;
const mins = Math.floor((TIMER_DURATIONS[context.facts.difficulty as Difficulty] - req.timeLeft) / 60);
const secs = (TIMER_DURATIONS[context.facts.difficulty as Difficulty] - req.timeLeft) % 60;
context.facts.message = `Solved in ${mins}m ${secs}s! Hints: ${req.hintsUsed}, Errors: ${req.errors}`;
},
},
gameOver: {
requirement: "GAME_OVER",
resolve: async (req, context) => {
context.facts.timerRunning = false;
context.facts.gameOver = true;
context.facts.won = false;
context.facts.message = req.reason;
},
},
revealHint: {
requirement: "REVEAL_HINT",
resolve: async (req, context) => {
const grid = [...(context.facts.grid as Grid)];
grid[req.index] = req.value;
context.facts.grid = grid;
// Clear notes for the hinted cell and remove value from peer notes
const notes = [...(context.facts.notes as Set<number>[])];
notes[req.index] = new Set();
for (const peer of getPeers(req.index)) {
if (notes[peer].has(req.value)) {
notes[peer] = new Set(notes[peer]);
notes[peer].delete(req.value);
}
}
context.facts.notes = notes;
context.facts.hintRequested = false;
context.facts.hintsUsed = (context.facts.hintsUsed as number) + 1;
context.facts.message = `Hint revealed! ${MAX_HINTS - (context.facts.hintsUsed as number)} remaining.`;
},
},
},
// ============================================================================
// Effects
// ============================================================================
effects: {
timerWarning: {
deps: ["timerRemaining"],
run: (facts) => {
const remaining = facts.timerRemaining as number;
if (remaining === TIMER_EFFECT_WARNING) {
console.log("[Sudoku] 1 minute remaining!");
}
if (remaining === TIMER_EFFECT_CRITICAL) {
console.log("[Sudoku] 30 seconds remaining!");
}
},
},
gameResult: {
deps: ["gameOver"],
run: (facts) => {
if (facts.gameOver) {
if (facts.won) {
console.log(`[Sudoku] Puzzle solved! Difficulty: ${facts.difficulty}, Hints: ${facts.hintsUsed}, Errors: ${facts.errorsCount}`);
} else {
console.log(`[Sudoku] Game over: ${facts.message}`);
}
}
},
},
},
});

