clarityxo-ai
v0.1.0
Published
Pure-TypeScript AI engine for ClarityXO Tic-Tac-Toe
Downloads
170
Readme
clarityxo-ai
A pure-TypeScript AI engine for ClarityXO — a decentralized Tic-Tac-Toe game built on the Stacks blockchain.
This package has zero blockchain dependencies and works in Node.js, browsers, and edge runtimes. Feed it a board state (fetched from clarityxo-sdk or anywhere else), and get back the best move, a ranked list of all moves, a threat analysis, or a full simulation report.
Table of Contents
- Installation
- Quick Start
- API Reference
- Difficulty Levels
- How the Algorithms Work
- Project Structure
- Development
- Integration with clarityxo-sdk
- License
Installation
npm install clarityxo-aiOr, if you're inside the ClarityXO monorepo:
npm install clarityxo-ai -w frontend # add to the frontend workspaceQuick Start
import { AIEngine, Difficulty, emptyBoard } from 'clarityxo-ai';
const engine = new AIEngine({ difficulty: Difficulty.Hard });
const board = emptyBoard();
const move = engine.bestMove(board, 'X');
console.log(move); // { row: 1, col: 1 } — center is always optimal on move oneUsing with clarityxo-sdk
import { getBoard } from 'clarityxo-sdk'; // blockchain read
import { AIEngine, Difficulty } from 'clarityxo-ai'; // pure AI logic
const rawBoard = await getBoard(gameId); // [[CellValue × 3] × 3]
const engine = new AIEngine({ difficulty: Difficulty.Hard });
const move = engine.bestMove(rawBoard, 'X');
// submit `move` back to the blockchain via clarityxo-sdkAPI Reference
AIEngine
The main entry point for move selection.
import { AIEngine, Difficulty } from 'clarityxo-ai';
const engine = new AIEngine({
difficulty: Difficulty.Hard,
mctsIterations: 500, // only relevant when difficulty === MCTS
});bestMove(board, player): Move | null
Returns the single best move for player on the given board. Returns null if the board is full.
const move = engine.bestMove(board, 'X');
// { row: 0 | 1 | 2, col: 0 | 1 | 2 }rankedMoves(board, player): ScoredMove[]
Returns every legal move sorted best-to-worst with minimax scores. Always uses full (perfect-play) minimax regardless of the engine's configured difficulty — useful for move analysis and UI overlays.
const ranked = engine.rankedMoves(board, 'X');
// [
// { row: 0, col: 2, score: 1, depth: 1 }, // winning move
// { row: 1, col: 0, score: 0, depth: 1 }, // draw
// ...
// ]Scores are from the current player's perspective: +1 is a forced win, 0 is a draw, -1 is a loss.
GameAnalyzer
Deep position analysis with threat detection and human-readable explanations.
import { GameAnalyzer } from 'clarityxo-ai';
const analyzer = new GameAnalyzer();
const report = analyzer.analyze(board, 'X');analyze(board, player): AnalysisReport
Analyzes the position for player to move. The analysis pipeline runs in priority order:
- Immediate win — does
playerhave a win-in-1? - Forced block — does the opponent have a win-in-1?
- Fork opportunity — can
playercreate two simultaneous threats? - Minimax evaluation — is the position theoretically won, drawn, or lost?
{
evaluation: 'winning' | 'losing' | 'draw' | 'neutral',
bestMove: { row: 1, col: 2 } | null,
threats: [
{
player: 'O',
direction: 'row',
index: 0,
missingCell: { row: 0, col: 2 },
type: 'win-in-1',
},
// ...
],
explanation: "Block (0, 2) or you lose next turn.",
}Simulator
Run self-play simulations between any two AI difficulty levels.
import { Simulator, Difficulty } from 'clarityxo-ai';
const sim = new Simulator();
const result = await sim.run({
games: 1000,
playerX: Difficulty.Hard,
playerO: Difficulty.Easy,
});
console.log(result);
// { xWins: 982, oWins: 0, draws: 18, totalGames: 1000, avgMoves: 7.4 }Work is chunked in batches of 100 games via setTimeout so the event loop stays responsive in browsers and edge runtimes.
run(options): Promise<SimulationResult>
| Option | Type | Description |
|---|---|---|
| games | number | How many complete games to simulate |
| playerX | Difficulty | Algorithm used for the X player |
| playerO | Difficulty | Algorithm used for the O player |
| mctsIterations | number? | MCTS iteration budget (default: 500) |
Board Utilities
Pure functions — none of them mutate anything.
import {
emptyBoard,
applyMove,
checkWin,
isBoardFull,
isDraw,
getAvailableMoves,
opponent,
} from 'clarityxo-ai';| Function | Signature | Description |
|---|---|---|
| emptyBoard | () → Board | Creates a fresh 3×3 board of nulls |
| applyMove | (board, move, player) → Board | Returns a new board with the move applied (never mutates) |
| checkWin | (board, player) → boolean | Returns true if player has three in a row |
| isBoardFull | (board) → boolean | Returns true when no null cell remains |
| isDraw | (board) → boolean | Board is full and neither player won |
| getAvailableMoves | (board) → Move[] | All {row, col} positions where board[row][col] === null |
| opponent | (player) → Player | 'X' → 'O', 'O' → 'X' |
Types
// A 3×3 grid of cells
type Board = [[CellValue, CellValue, CellValue], ...]
// Individual cell — null means empty
type CellValue = 'X' | 'O' | null;
// One of the two players
type Player = 'X' | 'O';
// A board position
interface Move { row: 0 | 1 | 2; col: 0 | 1 | 2; }
// A move with its minimax evaluation
interface ScoredMove extends Move { score: number; depth: number; }
// A detected tactical threat
interface Threat {
player: Player;
direction: 'row' | 'col' | 'diag';
index: number;
missingCell: Move;
type: 'win-in-1' | 'fork';
}
// Full position report from GameAnalyzer
interface AnalysisReport {
evaluation: 'winning' | 'losing' | 'draw' | 'neutral';
bestMove: Move | null;
threats: Threat[];
explanation: string;
}
// Aggregate stats from Simulator.run()
interface SimulationResult {
xWins: number;
oWins: number;
draws: number;
totalGames: number;
avgMoves: number;
}Difficulty Levels
| Level | Algorithm | Character |
|---|---|---|
| Easy | Random legal move | Picks blindly — great for beginners or as a rollout baseline |
| Medium | Minimax depth-2 | Sees one exchange ahead — blocks obvious threats but misses deeper tactics |
| Hard | Full minimax + alpha-beta | Perfect play — never loses, always wins when possible |
| MCTS | Monte Carlo Tree Search | Probabilistic — strength scales with mctsIterations; interesting mid-game behavior |
import { Difficulty } from 'clarityxo-ai';
Difficulty.Easy // 'easy'
Difficulty.Medium // 'medium'
Difficulty.Hard // 'hard'
Difficulty.MCTS // 'mcts'How the Algorithms Work
Minimax with Alpha-Beta Pruning
Minimax is a classic adversarial search algorithm. It builds a complete game tree, assuming both players always play optimally, and returns the move that maximises the current player's outcome.
Negamax formulation — rather than separate max and min branches, negamax flips the sign of the score at each level:
negamax(node):
if terminal: return score from current player's perspective
return max over children of −negamax(child)Alpha-beta pruning cuts branches of the search tree that cannot possibly influence the final decision. In Tic-Tac-Toe this is crucial: it reduces the worst-case node count from 9! ≈ 362,880 to well under 10,000 with good move ordering.
Move ordering (center → corners → edges) is applied before each search level. Because center and corner moves tend to be stronger, the engine finds good cutoff bounds early, making alpha-beta significantly more effective.
Depth-scaled scoring — winning scores are divided by depth (1 / depth), so the engine prefers faster wins and defers losses for as many moves as possible. This makes gameplay feel aggressive rather than passive.
Monte Carlo Tree Search (MCTS)
MCTS trades deterministic correctness for sampling efficiency. It estimates move strength by simulating thousands of random games and tracking win rates.
Each iteration runs four steps:
1. Selection — walk the tree from root, always choosing the child
with the highest UCB1 score:
UCB1 = wins/visits + √2 · √(ln(parentVisits) / visits)
2. Expansion — at the selected node, try a random untried move
and create a new child node.
3. Simulation — from the new node, play random moves until a
terminal state is reached.
4. Backprop — walk back up the tree incrementing visit counts
and win counts at every ancestor.The final move is the child of the root with the most visits (rather than the highest win rate), which reduces variance on low iteration budgets.
UCB1's exploration term (√2 · √(ln(N)/n)) balances exploitation (visiting moves that seem strong) with exploration (trying moves that haven't been sampled enough). C = √2 is the standard theoretical value for games with binary win/loss outcomes.
Project Structure
clarityxo-ai/
├── src/
│ ├── index.ts # public barrel — re-exports everything
│ ├── types.ts # Board, Player, Move, Difficulty, and all interfaces
│ ├── utils.ts # pure board primitives (checkWin, applyMove, …)
│ ├── algorithms/
│ │ ├── random.ts # random legal move — Easy difficulty + MCTS rollout
│ │ ├── minimax.ts # negamax + alpha-beta + move ordering
│ │ └── mcts.ts # Monte Carlo Tree Search with UCB1 selection
│ ├── engine.ts # AIEngine — unified bestMove / rankedMoves API
│ ├── analyzer.ts # GameAnalyzer — threat detection + explanation
│ └── simulator.ts # Simulator — async self-play batch runner
├── tests/
│ ├── utils.test.ts
│ ├── minimax.test.ts
│ ├── mcts.test.ts
│ ├── engine.test.ts
│ ├── analyzer.test.ts
│ └── simulator.test.ts
├── package.json
├── tsconfig.json
└── vitest.config.tsDevelopment
# Install dependencies
npm install
# Run all tests
npm test
# Type-check without emitting
npm run typecheck
# Build to dist/
npm run build
# Smoke test after build
node --input-type=module <<'EOF'
import { AIEngine, Difficulty, emptyBoard } from './dist/index.js';
const ai = new AIEngine({ difficulty: Difficulty.Hard });
console.log(ai.bestMove(emptyBoard(), 'X')); // { row: 1, col: 1 }
EOFTest coverage summary
| File | Tests | What's covered |
|---|---|---|
| utils.test.ts | 13 | Win detection (rows, cols, diagonals), draw detection, move enumeration, immutable applyMove |
| minimax.test.ts | 5 | Takes an immediate win, blocks a forced loss, prefers faster wins, Hard vs Hard draws |
| mcts.test.ts | 4 | Takes immediate win, blocks opponent win-in-1, null on full board, returns legal move |
| engine.test.ts | 8 | All four difficulty levels, center on empty board, rankedMoves ordering |
| analyzer.test.ts | 6 | win-in-1 detection, forced-block detection, fork detection, draw evaluation, non-empty explanation |
| simulator.test.ts | 4 | Hard vs Hard → 100% draws, Hard vs Easy → X wins >90%, total game counts, avgMoves sanity |
Integration with clarityxo-sdk
clarityxo-ai and clarityxo-sdk are intentionally decoupled. The SDK handles blockchain I/O (reading game state, submitting moves as Stacks transactions); this package handles only the game logic and AI decision-making.
Typical integration pattern:
import { fetchGame, submitMove } from 'clarityxo-sdk';
import { AIEngine, GameAnalyzer, Difficulty } from 'clarityxo-ai';
const game = await fetchGame(gameId);
const engine = new AIEngine({ difficulty: Difficulty.Hard });
const analyzer = new GameAnalyzer();
// Analyze the current position
const report = analyzer.analyze(game.board, game.currentPlayer);
console.log(report.explanation);
// Pick and submit the best move
const move = engine.bestMove(game.board, game.currentPlayer);
if (move) await submitMove(gameId, move);License
MIT © Dominion116
