// perft on the definition line, or named perft) at depths 1..N and compares the node counts to the well-known expected values for the selected position.Start by returning any legal move to confirm everything is wired up correctly. Use the simulate buttons to step through moves and check your engine is responding as expected. As you add evaluation and search logic, save snapshots of your engine and use the batch test to run versions against each other so you can see if your changes were an improvement.
An engine in this app is one function, findBestMove(chess, search), that returns the chosen move. The chess / Move / Search surface (one object holds the position, one namespace decodes moves, one class carries the search budget) is all you need. See the next section for the full reference and a minimal engine.
For drop-in compatibility with an existing JavaScript UCI engine that speaks UCI over postMessage, set const ENGINE_API = 'uci-worker'; the app then routes uci, isready, position, go, etc. to your worker and parses standard bestmove / info output.
The linked Chess Programming Wiki pages cover deeper topics like piece-square tables, alpha-beta search, move ordering, and quiescence search.
findBestMove(chess, search). Return the chosen move as an encoded int. The natural source is chess.legalMoves(); Move.fromUci('e2e4') also works if you have the move in UCI text form.const ENGINE_NAME = 'My Engine' to control the displayed name.search.depth is the requested max search depth. search.timeMs is the move-time budget in ms. Use whichever applies; search.shouldStop() covers both budgets and external aborts in a single call.chess objectA single object holds the position. Read state via properties, mutate via methods.
chess.board (Int8Array(64), piece codes 0..12), chess.turn (0=white, 1=black), chess.halfmoves, chess.fullMove, chess.enPassant (-1 if none, else target square 0..63), chess.castling (bitmask 1/2/4/8), chess.zobristLo / chess.zobristHi, chess.lastMove, chess.pieceCount, chess.kingSq(c).chess.fen(), chess.setFen(s), chess.reset(). Chess.fromFen(s) builds a new instance.chess.legalMoves() returns an Int32Array view (pooled, no per-call allocation). chess.captures() returns the captures+promotions subset for qsearch. chess.isLegal(m) for verification.chess.make(m) applies a move atomically (handles halfmove clock, promotion, turn flip, repetition tracking). chess.unmake() reverses it. Use chess.makeUnsafe(m) / chess.unmakeUnsafe() inside the search hot loop to skip repetition tracking (faster; safe in qsearch where repetitions can't form). chess.makeNull() / chess.unmakeNull() flip side-to-move without moving a piece, for null-move pruning; never call when in check.chess.isCheck(), chess.isCheckmate(), chess.isStalemate(), chess.isFiftyMoveRule(), chess.isThreefoldRepetition(), chess.isInsufficientMaterial(), chess.isDraw(), chess.isGameOver(), chess.result().chess.toAscii() for a text diagram.Move namespaceMoves are 32-bit encoded ints: from(6) | to(6) | flags(4). Decode and stringify via the Move namespace.
Move.from(m), Move.to(m), Move.flags(m), Move.isPromotion(m), Move.isCastle(m), Move.isEnPassant(m), Move.isCapture(m), Move.promoChar(m).Move.uci(m) (e.g. "e2e4", "e7e8q"), Move.san(m) (e.g. "Nf3", "O-O").Move.fromUci('e2e4') and Move.fromSan('Nf3') parse text against the current legal moves and return an encoded int (or 0 if illegal/unmatched). The encoded ints in chess.legalMoves() already carry castle/EP/promo flags; you only need parsing when the move is coming in as text.Move.NORMAL, Move.EN_PASSANT, Move.CASTLE_K, Move.CASTLE_Q, Move.PROMO_Q, Move.PROMO_R, Move.PROMO_B, Move.PROMO_N.Search classCarries the search budget and tracks elapsed time / nodes. Construct one per search.
new Search({ depth, timeMs, nodeBudget }); any field is optional; -1 / 0 means "no cap".search.nodes (increment as you visit nodes). Call search.report({depth, score, move, nodes}) at the end of each iterative-deepening pass so the app can display live stats.search.shouldStop() (true when time/node budget exceeded or external abort). search.elapsedMs(). Check periodically (every ~4096 nodes is the standard pattern; cheaper than a per-node check).search.aborted = true if the user clicks Stop. Cheap to check directly: if (search.aborted) return 0.const ENGINE_NAME = 'Minimal';
const VALUES = [0, 100, 320, 330, 500, 900, 0]; // P N B R Q K (king has no material value)
function evaluate(chess) {
let score = 0;
const board = chess.board;
for (let sq = 0; sq < 64; sq++) {
const p = board[sq];
if (!p) continue;
const v = VALUES[p <= 6 ? p : p - 6];
score += p <= 6 ? v : -v;
}
return chess.turn === 0 ? score : -score;
}
function negamax(chess, search, depth, alpha, beta) {
if (search.aborted) return 0;
if (depth === 0) return evaluate(chess);
search.nodes++;
if ((search.nodes & 4095) === 0 && search.shouldStop()) {
search.aborted = true;
return 0;
}
const moves = chess.legalMoves();
if (moves.length === 0) return chess.isCheck() ? -30000 + depth : 0;
let best = -Infinity;
for (let i = 0; i < moves.length; i++) {
chess.makeUnsafe(moves[i]);
const score = -negamax(chess, search, depth - 1, -beta, -alpha);
chess.unmakeUnsafe();
if (score > best) best = score;
if (best > alpha) alpha = best;
if (alpha >= beta) break;
}
return best;
}
function findBestMove(chess, search) {
let bestMove = 0, bestScore = -Infinity;
const rootMoves = chess.legalMoves();
for (let d = 1; d <= (search.depth > 0 ? search.depth : 128) && !search.shouldStop(); d++) {
let alpha = -Infinity;
let dBest = 0, dBestScore = -Infinity;
for (let i = 0; i < rootMoves.length; i++) {
chess.make(rootMoves[i]);
const score = -negamax(chess, search, d - 1, -Infinity, -alpha);
chess.unmake();
if (search.aborted) break;
if (score > dBestScore) { dBestScore = score; dBest = rootMoves[i]; }
if (score > alpha) alpha = score;
}
if (!search.aborted) {
bestMove = dBest;
bestScore = dBestScore;
search.report({ depth: d, score: bestScore, move: bestMove, nodes: search.nodes });
}
}
return bestMove;
}chess; position state and operationschess.board (Int8Array(64), piece code 0..12 per square), chess.turn (0 white, 1 black), chess.enPassant (-1 if none, else target square 0..63), chess.castling (bitmask: 1=wK, 2=wQ, 4=bK, 8=bQ), chess.halfmoves, chess.fullMove, chess.zobristLo / chess.zobristHi (two Int32s; use as your TT key), chess.lastMove, chess.pieceCount, chess.kingSq(c).chess.fen(), chess.setFen(fen), chess.reset(); Chess.fromFen(fen) constructs a new instance.chess.legalMoves() → Int32Array view (pooled, no allocation); chess.captures() → captures + EP + promotions only (use in qsearch); chess.isLegal(m).chess.make(m) applies the move atomically (halfmove clock, promotion, turn flip, repetition tracking all handled). chess.unmake() reverses it. chess.makeUnsafe(m) / chess.unmakeUnsafe() skip repetition tracking; use these inside qsearch where repetitions can't form (faster). chess.makeNull() / chess.unmakeNull() flip side-to-move without moving a piece (zobrist hash and en-passant square are updated correctly); pair them like make/unmake. Never call makeNull when the side to move is in check, and skip it in zugzwang-prone endings (pawn-only positions).chess.isCheck(), chess.isCheckmate(), chess.isStalemate(), chess.isFiftyMoveRule(), chess.isThreefoldRepetition(), chess.isInsufficientMaterial(), chess.isDraw() (any of the four above), chess.isGameOver() (mate OR draw), chess.result() ('1-0' | '0-1' | '1/2-1/2' | '*').chess.toAscii() for a text diagram.Move; encoded move handlesMoves are 32-bit ints: from(6) | to(6) | flags(4). The Move namespace is the single place to decode, stringify, or build them.
Move.from(m), Move.to(m), Move.flags(m), Move.isPromotion(m), Move.isCastle(m), Move.isEnPassant(m), Move.isCapture(m), Move.promoChar(m) ('q' | 'r' | 'b' | 'n' | '').Move.uci(m) (e.g. 'e2e4', 'e7e8q'), Move.san(m) (e.g. 'Nf3', 'O-O', 'exd5').Move.fromUci('e2e4') and Move.fromSan('Nf3') parse text against the current legal moves and return an encoded int (or 0 if illegal/unmatched). Castle / EP / promo flags are auto-detected. You usually don't need parsing inside a search: just use the ints from chess.legalMoves() directly.Move.NORMAL, Move.EN_PASSANT, Move.CASTLE_K, Move.CASTLE_Q, Move.PROMO_Q, Move.PROMO_R, Move.PROMO_B, Move.PROMO_N.Search; budget and progressconst search = new Search({ depth, timeMs, nodeBudget }). Each field is optional; -1/0 means "no cap".search.nodes++ in your inner search; search.report({depth, score, move, nodes}) at the end of each iterative-deepening pass (drives the live UI readout).search.shouldStop() (one call covers time, nodes, and external abort), search.elapsedMs(). Standard pattern is to check every ~4096 nodes for speed.search.aborted becomes true if the user clicks Stop. Cheap to check directly per node: if (search.aborted) return 0.Pieces are integers 0..12 (0 empty). Named constants are exposed: wP=1, wN=2, wB=3, wR=4, wQ=5, wK=6, bP=7, bN=8, bB=9, bR=10, bQ=11, bK=12. Helpers: isP(p), isN(p), isB(p), isR(p), isQ(p), isK(p), isWhite(p), isBlack(p), colorOf(p), enemyOf(c), type(p) (returns 'P'..'K').
chess.isThreefoldRepetition() checks the game-level history, which doesn't reflect positions reached inside your search tree. If you need repetition detection during search, maintain your own per-search repetition table (the built-in engine uses three small typed arrays sized as a hash, updated on every make and reversed on unmake). Or just use chess.makeUnsafe / chess.unmakeUnsafe inside the recursive search and accept that you won't detect repetitions until the root sees them.
When the side to move is in check, qsearch can't simply stand_pat on the static eval; there's no "do nothing" option, the king must escape. The standard pattern is: at the top of qsearch, if chess.isCheck(), set stand_pat = -Infinity and use chess.legalMoves() (full move set so escapes are considered). When not in check, use chess.captures() and stand-pat normally. Forgetting the in-check branch is a silent correctness hole if your search has check extensions.
If you store a transposition table, raw mate scores (anything with Math.abs(score) >= 9000) need to be adjusted by ply on store and probe, or you'll cache "mate in N" at one search depth and read it back at another as if it were "mate in N" again. The standard pattern:
const MATE = 30000;
const MATE_BOUND = 9000;
function scoreToTT(score, ply) { if (score >= MATE_BOUND) return score + ply; if (score <= -MATE_BOUND) return score - ply; return score; }
function scoreFromTT(score, ply) { if (score >= MATE_BOUND) return score - ply; if (score <= -MATE_BOUND) return score + ply; return score; }Call scoreToTT(score, ply) before storing; call scoreFromTT(entry.score, ply) after probing. Without this, your search will fish out a "mate in 3" entry stored at ply 10, return it at ply 2, and announce a fake forced mate that doesn't exist. Classic first-time-TT bug.
If the side to move is allowed to "pass" (do nothing) and the opponent still can't beat beta, the position is already so winning that searching real moves is wasteful. chess.makeNull() / chess.unmakeNull() implement the pass. Use it inside negamax like this:
if (!isPv && !chess.isCheck() && depth >= 3 && staticEval >= beta) {
if (hasNonPawnMaterial(chess)) { // zugzwang guard, see below
chess.makeNull();
const R = 3; // reduction; 3-4 is typical
const nullScore = -negamax(chess, search, -beta, -beta + 1, depth - R, ply + 1);
chess.unmakeNull();
if (nullScore >= beta) return nullScore;
}
}Two hard rules: never call makeNull when the side to move is in check (you'd give the opponent two moves in a row including against an undefended king), and skip null-move in positions where the side to move has only pawns and a king (these positions are zugzwang-prone — moving is sometimes worse than not moving, which violates the assumption null-move depends on). A simple hasNonPawnMaterial helper that scans chess.board for any N/B/R/Q of the side to move is enough.
SEE answers "if I play this capture and the opponent recaptures and I recapture, what's the net material outcome?" Used for move ordering (try winning captures first) and qsearch pruning (skip losing captures). Chessforge doesn't ship a helper; the canonical algorithm is on the wiki and is ~30 lines of board-iteration. Worth building once you've got the basics working — typically adds 100-200 Elo via move ordering alone.
The Build tab has a move-gen toggle at the top: built-in vs custom. Switch it to custom and the app stops drawing the board entirely. You take over rendering by exporting four functions. The container, piece graphics, board theme, animations, and click detection are still provided. The board logic (square topology, piece positions, move rules) becomes yours.
Two functions are required; two more are optional:
// REQUIRED; describe the squares of your board
function getSquares() {
return [
{ id: 'a1', x: 0, y: 0.875, w: 0.125, h: 0.125, color: 'dark' },
{ id: 'b1', x: 0.125, y: 0.875, w: 0.125, h: 0.125, color: 'light' },
// ... one entry per square
];
}
// REQUIRED; where each piece sits right now
function getPieces() {
return [
{ type: 'wK', squareId: 'e1' },
{ type: 'bP', squareId: 'd7' },
// ...
];
}
// OPTIONAL; destinations to highlight when this square is selected
function getLegalMoves(squareId) {
return ['d4', 'e4', 'f4'];
}
// OPTIONAL; called when the user completes a move on the UI
function onMove(fromId, toId) {
// update your own state; the next render picks it up
}x, y, w, h are fractions of the board container (range 0..1). x=0, y=0 is top-left. Sizes don't have to fill the container or tile evenly; you can leave gaps, overlap, or build hex grids.id is any string you like; it's how you refer to the square in getPieces, getLegalMoves, and onMove.color: the strings 'light' and 'dark' pick up the user's current board theme. Any other value passes through as a CSS color ('#ff0080', 'rgb(...)', etc.).Use two-character codes: w or b for color, then P N B R Q K for the piece. So: wP wN wB wR wQ wK bP bN bB bR bQ bK. Graphics come from the user's currently-selected piece set (Settings > Pieces). If they change sets, your board re-renders automatically. Pieces auto-center in their square and scale with the user's piece-scale slider.
getLegalMoves for highlight, then onMove to applygetLegalMoves and any square becomes a valid destination)function getSquares() {
const out = [];
for (let r = 0; r < 8; r++) { // 0 = top row (rank 8)
for (let c = 0; c < 8; c++) { // 0 = left col (file a)
out.push({
id: 'abcdefgh'[c] + (8 - r),
x: c / 8, y: r / 8, w: 1 / 8, h: 1 / 8,
color: (r + c) % 2 === 0 ? 'light' : 'dark'
});
}
}
return out;
}
let board = {
a8:'bR', b8:'bN', c8:'bB', d8:'bQ', e8:'bK', f8:'bB', g8:'bN', h8:'bR',
a7:'bP', b7:'bP', c7:'bP', d7:'bP', e7:'bP', f7:'bP', g7:'bP', h7:'bP',
a2:'wP', b2:'wP', c2:'wP', d2:'wP', e2:'wP', f2:'wP', g2:'wP', h2:'wP',
a1:'wR', b1:'wN', c1:'wB', d1:'wQ', e1:'wK', f1:'wB', g1:'wN', h1:'wR'
};
function getPieces() {
return Object.entries(board).map(([squareId, type]) => ({ type, squareId }));
}
function onMove(fromId, toId) {
if (!board[fromId]) return;
board[toId] = board[fromId];
delete board[fromId];
}From there, add getLegalMoves(squareId) to filter destinations to actual chess moves; the app will only highlight (and allow) those.
Play, Analyze, and Batch always use the canonical core for the UI. Your custom getSquares / getPieces functions are only used in Build mode with the toggle on custom. Live games still go through findBestMove as usual; the custom rendering is purely a development scaffold for your own move-gen.
The Play tab, Analyze tab, and bottom status bar all show live readouts of the engine's search: score (centipawns), depth, nodes searched, and NPS (nodes per second).
Call search.report({ depth, score, move, nodes }) at the end of each iterative-deepening iteration. The app reads the most recent report after findBestMove returns.
depth: the iteration that just completed.score: a number in centipawns from the side-to-move's perspective. Positive means the side to move is winning. Mate scores are typically 30000 - plyToMate (and negated for the losing side); the app recognises any score with Math.abs(score) >= 9000 as "mate-in-N".move: the best move you'd play (encoded int or UCI string).nodes: total node count so far. You can just pass search.nodes if you've been incrementing it.NPS is computed by the app as nodes / elapsedMs; you don't need to set it yourself. Just keep search.nodes accurate.
function negamax(chess, search, depth, alpha, beta) {
search.nodes++; // count this node
if (depth === 0) return evaluate(chess);
// ... normal alpha-beta body ...
}
function findBestMove(chess, search) {
let bestMove = 0, bestScore = 0;
const maxDepth = search.depth > 0 ? search.depth : 128;
for (let d = 1; d <= maxDepth && !search.shouldStop(); d++) {
const [move, score] = searchRoot(chess, search, d); // your root routine
if (search.aborted) break;
bestMove = move;
bestScore = score;
search.report({ depth: d, score, move, nodes: search.nodes });
}
return bestMove;
}info depth N score +X.YZ on one line, then pv ..., nps ... nodes ..., tt ... hit ... MB on subsequent lines.nps=X nodes=Y on the right side.bestmove / depth / score after a one-shot search.If you don't call search.report, every search still works but the readouts show 0 / no useful info. UCI-protocol engines have a separate path: their info depth ... score cp ... lines are parsed directly, no calls needed.
The Texel tab can automatically tune any numeric constant in your engine source. There are two tuners with different requirements:
Math.trunc, branches, integer division). Slow for large param counts but always applicable.MG_VAL[type] * count, MG_PST[type][sq] * piecePresent). Fast for hundreds-to-thousands of params (material, mobility, PSTs). Skips anything you mark as tuner: coordinate.Both tuners read the same engine state, switch methods freely from the UI dropdown. The unified groups list shows DSL block names; checkboxes default to the appropriate set per method (gradient-tunable blocks under gradient, coord-only blocks under coordinate).
tune`...` DSL block (preferred)Declare tunables in a single block near the top of your engine. The tune identifier is a no-op tagged template at runtime; the framework parses it statically.
tune`
// Gradient-tunable: linear in the param, fast Adam updates.
material-mg {
target: MG_VAL
indices: 1..5
range: [50, 2000]
step: 5
feature: piece_count_by_type
phase: mg
pair: material-eg
}
material-eg {
target: EG_VAL
indices: 1..5
range: [50, 2000]
step: 5
feature: piece_count_by_type
phase: eg
pair: material-mg
}
pst-mg {
target: MG_PST
shape: [6, 64]
range: [-200, 200]
step: 1
feature: piece_on_square
phase: mg
pair: pst-eg
}
// Coord-only: non-linear in the param (Math.trunc, conditional branches),
// so gradient would be biased. Tuner: coordinate restricts it.
king-safety-mull {
target: KS_MULL
indices: 1..4
range: [0, 80]
step: 1
tuner: coordinate
}
`;Fields:
target: the array (or arrays, space-separated) the block tunes. MG_VAL, MG_PST, etc. bare identifiers, not strings.indices: A..B or indices: [a, b, c]: which 1D positions to tune. Omit for the whole array (paired with shape).shape: [rows, cols]: 2D layout (PSTs use [6, 64]). Total params = rows × cols.range: [lo, hi]: clamp range applied every step. Both tuners respect it.step: coord-descent step size; gradient initialises Adam state from it.feature: which built-in feature extractor describes how the param contributes to eval. Gradient tuning needs this. Built-ins: piece_count_by_type (material), piece_on_square (PSTs), mobility_by_type (mobility weights). Use $funcName to point at a global function you defined yourself (see custom features below).phase: mg or phase: eg: scales the feature by the middlegame / endgame phase factor. Without this, mg/eg pairs are rank-deficient and Adam moves them in lockstep.pair: name of the companion block this one shares features with (used for diagnostics; doesn't change the math).tuner: coordinate: marks the block coord-only. Gradient will not touch it.lock: true: skip the block entirely.symmetry: horizontal (PSTs only, currently advisory): hints that left/right files mirror.If a built-in doesn't fit, write your own. Define a global function and reference it with the $ prefix. The function receives the resolved block and an emit(localIndex, weight) callback; emit one tuple per nonzero contribution.
// Custom: param[i] is the weight for "isolated pawn on file i".
globalThis.feat_isolated_pawn = function(block, emit) {
for (let f = 0; f < 8; f++) {
const w = countIsolated(WHITE, f) - countIsolated(BLACK, f);
if (w !== 0) emit(f, w);
}
};
tune`
isolated-mg {
target: ISOLATED_MG
indices: 0..7
range: [-50, 0]
step: 1
feature: $feat_isolated_pawn
phase: mg
}
`;// tune ... markers (coord-descent only)The older comment-based syntax still works in parallel, useful for one-off scalars where a DSL block would be overkill. Put // tune group=NAME min=A max=B step=C at the end of the line; the last numeric literal on that line becomes tunable.
if (p === ownRook) return 20; // tune group=passers min=0 max=60 step=1 if (p === enemyRook) return -24; // tune group=passers min=-70 max=0 step=1
For array literals, add idx=N, idx=N-M, or idx=N,M,P to pick specific elements:
const MOB_MG = [0, 0, 4, 5, 2, 1, 0]; // tune group=mobility min=-10 max=25 step=1 idx=2-5
// Pick the 2nd and 4th scalar literals on the line (the 10 and 18):
if (wPawns > 1) { pawnMg -= 10 * (wPawns - 1); pawnEg -= 18 * (wPawns - 1); } // tune group=pawns min=0 max=50 step=1 idx=1,3Inline markers create coord-only params; gradient tuning ignores them. If a DSL block target: covers the same array, the DSL block takes over the UI group (so you don't see two checkboxes for the same values) but the line-level positions are still used to write tuned values back into the literals.
Any const NAME_PST = [null, [64 ints], ..., [64 ints]] with the canonical 6×64 shape is picked up automatically, no markers needed. The variable name must contain "PST". Pawn rank-1 and rank-8 cells are skipped (pawns can't sit there); the other 368 cells are tunable.
For gradient tuning, add a DSL block on top so the tuner knows the feature and phase:
tune`
pst-mg {
target: MG_PST
shape: [6, 64]
range: [-200, 200]
step: 1
feature: piece_on_square
phase: mg
pair: pst-eg
}
`;
const MG_PST = [
null,
[/* 64 pawn values */],
[/* 64 knight values */],
/* ...rook, bishop, queen, king */
];For non-standard PSTs (different name or piece order), explicit // tune-pst markers still work:
// tune-pst group=mg-custom pieces=PNBRQK min=-100 max=150 step=2
const MY_TABLE = [null, [...], [...], [...], [...], [...], [...]];
// Or inline on the opening bracket:
const ALT_PST = [ // tune-pst group=alt pieces=KQRBNP min=-50 max=80 step=2
null, [...], [...], [...], [...], [...], [...]
];Use the method dropdown in the Texel tab to switch between coordinate descent and gradient. The groups list and parameter table stay the same; checkboxes default to:
feature: field are ON, coord-only blocks (tuner: coordinate) are OFF.When the tuner finishes, click Save as version. Tuned values are written back into the source at the exact character offsets the tuner recorded, so the array literals show the new numbers directly, formatting, comments, and surrounding logic stay untouched. The result lands as a new project in the sidebar (named YourEngine (tuned) for coord descent, ... (gradient-tuned) for gradient).
// tune marker (so constants like 0.5 or 50 stay put if they aren't eval params).min=10 and step=1 are parsed as args, not as tunables.lock: true DSL block.The canonical perft is built into the core. The Batch tab's perft test runs it against the six standard positions from chessprogramming.org and verifies the counts match exactly. It's only worth writing your own perft if you've replaced the core's move generator (e.g. a uci-worker engine with its own board representation).
To plug your move generator into the Batch tab's perft test, mark its definition line with a // perft comment. The test will call it for each requested depth.
function myPerft(depth) { // perft
myParseFEN(chess.fen());
return _perftSearch(depth);
}
function _perftSearch(depth) {
if (depth === 0) return 1;
let nodes = 0;
const moves = myGenerateLegalMoves();
for (const move of moves) {
myMakeMove(move);
nodes += _perftSearch(depth - 1);
myUnmakeMove(move);
}
return nodes;
}If your function is literally named perft, the marker is optional.
chess APIHere's what perft looks like written against the canonical chess object; useful as a reference or as a sanity check for your own implementation:
function perft(depth) {
if (depth === 0) return 1;
const moves = chess.legalMoves();
if (depth === 1) return moves.length;
let nodes = 0;
for (let i = 0; i < moves.length; i++) {
chess.makeUnsafe(moves[i]);
nodes += perft(depth - 1);
chess.unmakeUnsafe();
}
return nodes;
}For the six standard positions the test compares against hardcoded reference counts. For "Current board" it computes the expected count on the fly using the canonical core's perft, so you can validate against arbitrary positions (slower; canonical perft runs alongside yours).
Reference values: Chess Programming Wiki perft results.