Skip to main content

Examples

Sudoku

Constraint satisfaction, powered by Directive. The game rules ARE the constraints.

Play it

Loading example…

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.

  1. Facts – Grid state, solution, givens, timer, selection, notes, difficulty
  2. Derivations – Conflicts, progress, timer display, same-number highlighting, candidates (auto-tracked, no manual deps)
  3. Events selectCell, inputNumber, toggleNote, requestHint, tick, newGame
  4. Constraints timerExpired (priority 200), detectConflict (100), puzzleSolved (90), hintAvailable (70) – evaluated by priority after every fact change
  5. Resolvers – Handle game won/lost, increment error count, reveal hints
  6. 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}`);
          }
        }
      },
    },
  },
});

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works