~/CF
100%
87654321
abcdefgh
0.0
bestmove,
depth,
score,
nps,
game

Play

Ready
human
engine vs engine
board
search limit
move generator

batch test

Built-in engine
    Built-in engine
      Results are W/D/L from A's perspective.
      batch history
      Built-in engine
        Benchmarks search speed on a fixed position suite without running full games.
        Built-in engine
          Calls the engine's own perft function (marked with // 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.
          // 0 positions
          // no suite loaded
          Built-in engine

            Build

            No engine open
            move-gen
            projects
            docs
            • Getting started
            • Writing an engine
            • API reference
            • Custom board reps
            • Showing search stats
            • Tuner markers
            • Testing with perft
            template
            theme

            engine code

            Getting started
            First working version

            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.

            How to write an engine for this app
            The contract
            • Expose one global function: 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.
            • Optional: set 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.
            The chess object

            A single object holds the position. Read state via properties, mutate via methods.

            • Position state (read-only): 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).
            • FEN: chess.fen(), chess.setFen(s), chess.reset(). Chess.fromFen(s) builds a new instance.
            • Move generation: 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.
            • Make / unmake: 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.
            • Game end: chess.isCheck(), chess.isCheckmate(), chess.isStalemate(), chess.isFiftyMoveRule(), chess.isThreefoldRepetition(), chess.isInsufficientMaterial(), chess.isDraw(), chess.isGameOver(), chess.result().
            • Debug: chess.toAscii() for a text diagram.
            The Move namespace

            Moves are 32-bit encoded ints: from(6) | to(6) | flags(4). Decode and stringify via the Move namespace.

            • Decode: Move.from(m), Move.to(m), Move.flags(m), Move.isPromotion(m), Move.isCastle(m), Move.isEnPassant(m), Move.isCapture(m), Move.promoChar(m).
            • Stringify: Move.uci(m) (e.g. "e2e4", "e7e8q"), Move.san(m) (e.g. "Nf3", "O-O").
            • Parse: 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.
            • Flag constants: Move.NORMAL, Move.EN_PASSANT, Move.CASTLE_K, Move.CASTLE_Q, Move.PROMO_Q, Move.PROMO_R, Move.PROMO_B, Move.PROMO_N.
            The Search class

            Carries 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".
            • You write: 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.
            • You read: 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).
            • The host writes: search.aborted = true if the user clicks Stop. Cheap to check directly: if (search.aborted) return 0.
            Minimal engine
            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;
            }
            API reference
            chess; position state and operations
            • Read-only state: chess.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).
            • FEN: chess.fen(), chess.setFen(fen), chess.reset(); Chess.fromFen(fen) constructs a new instance.
            • Move generation: chess.legalMoves()Int32Array view (pooled, no allocation); chess.captures() → captures + EP + promotions only (use in qsearch); chess.isLegal(m).
            • Make / unmake: 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).
            • Game-end: 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' | '*').
            • Debug: chess.toAscii() for a text diagram.
            Move; encoded move handles

            Moves are 32-bit ints: from(6) | to(6) | flags(4). The Move namespace is the single place to decode, stringify, or build them.

            • Decode: 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' | '').
            • Stringify: Move.uci(m) (e.g. 'e2e4', 'e7e8q'), Move.san(m) (e.g. 'Nf3', 'O-O', 'exd5').
            • Parse: 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.
            • Flag constants: 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 progress
            • Construct: const search = new Search({ depth, timeMs, nodeBudget }). Each field is optional; -1/0 means "no cap".
            • Engine writes: 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).
            • Engine reads: search.shouldStop() (one call covers time, nodes, and external abort), search.elapsedMs(). Standard pattern is to check every ~4096 nodes for speed.
            • Host writes (you read): search.aborted becomes true if the user clicks Stop. Cheap to check directly per node: if (search.aborted) return 0.
            Piece codes

            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').

            Repetition in search

            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.

            Qsearch in check

            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.

            TT mate scores

            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.

            Null-move pruning

            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.

            Static Exchange Evaluation (SEE)

            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.

            Writing an engine from scratch (custom board representation)

            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.

            The contract

            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
            }
            Coordinate system
            • 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.).
            Piece types

            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.

            What the app handles
            • Drawing the board container at the right aspect ratio
            • Rendering squares with the colors you specify
            • Placing pieces from the user's active piece set
            • Click hit-testing: clicks on a square dispatch via getLegalMoves for highlight, then onMove to apply
            • Live reload on every edit (debounced ~250ms)
            What you handle
            • Topology (8×8, 10×10, hex, irregular, whatever)
            • Piece tracking and position state
            • Move legality (omit getLegalMoves and any square becomes a valid destination)
            • Game rules: check, castling, en passant, promotion, draws, all yours
            Boilerplate: standard 8×8 board, starting position, free movement
            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.

            Outside the Build tab

            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.

            Showing search stats (score, depth, NPS, nodes)

            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).

            The contract

            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.

            Example
            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;
            }
            Where the stats appear
            • Play tab, engine output panel: info depth N score +X.YZ on one line, then pv ..., nps ... nodes ..., tt ... hit ... MB on subsequent lines.
            • Bottom status bar: nps=X nodes=Y on the right side.
            • Build tab readout: bestmove / depth / score after a one-shot search.
            • Analyze tab: per-move score values for the eval graph + accuracy.

            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.

            Marking code for the Texel tuner

            The Texel tab can automatically tune any numeric constant in your engine source. There are two tuners with different requirements:

            • Coordinate descent - perturbs one value at a time and keeps the change if loss drops. Works on any numeric literal you mark, including non-linear ones (Math.trunc, branches, integer division). Slow for large param counts but always applicable.
            • Gradient (Adam) - needs a linear relationship between each param and the eval (e.g. 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).

            The 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.
            Custom feature extractors

            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
                }
            `;
            Inline // 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,3

            Inline 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.

            Piece-square tables (auto-detected)

            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, [...], [...], [...], [...], [...], [...]
            ];
            Switching tuning methods

            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:

            • Gradient mode: blocks with a feature: field are ON, coord-only blocks (tuner: coordinate) are OFF.
            • Coordinate mode: coord-only blocks are ON, gradient-tunable blocks are OFF (they can still be coord-tuned by ticking them, just slower than gradient would be).
            What gets saved

            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).

            What the tuner won't touch
            • Literals not covered by a DSL block or a // tune marker (so constants like 0.5 or 50 stay put if they aren't eval params).
            • Numbers inside the marker comment itself: min=10 and step=1 are parsed as args, not as tunables.
            • Strings, regex literals, comments, anything that isn't a bare numeric literal on a tracked line.
            • Anything inside a lock: true DSL block.
            Testing move generation with perft

            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).

            Marking your perft function

            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.

            Reference: a minimal perft using the chess API

            Here'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;
            }
            Live expected counts

            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.

            analyze

            Game review

            accuracy
            move quality
            eval graph
            pst · pawn · mg

            Piece-square table

            64 cells · min=0 max=0 sum=+0
            piece
            phase
            preset
            import from engine
            tools
            stats
            min0
            max0
            sum+0
            mean0.0
            grid · pawn · mg · white perspective
            abcdefgh
            87654321
            0
            +0
            output · javascript
            
                        
            tuner · texel

            Texel tuning

            // idle · pick an engine and a position file
            engine
            positions
            // no file selected
            cp
            tune groups
            // load an engine to see tune markers
            method
            // coordinate descent: tunes any params with `// tune ...` markers
            eval kind
            static skips per-position search; ~10-30x faster. use qsearch only if your positions aren't already filtered to quiet ones.
            step size
            max epochs
            min improvement
            train sample size
            worker count
            checkpoints
            // none yet
            l_train
            ,
            l_val
            ,
            Δ vs start
            ,
            loss · 0 epochs · train + val
            parameters · 0 tuned
            // nothing to show yet
            log · tuner.stdout
            
                        
            pgn
            engine output