Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

XBoard, pondering and playing on Lichess

Stage 20 · tag stage-20-xboard · code in crates/protocol/src/xboard.rs, uci.rs and crates/cli/tests/conformance.rs

This is the last stage of the hand-crafted engine. Nothing here makes cb2026 search better; it makes the engine work in more places. It adds a second GUI protocol, thinking on the opponent’s time, a test suite that plays the part of the GUIs, and a setup for playing people online.

Two protocols, two ideas of who keeps the game

Chapter 4 introduced UCI, where the GUI owns the game. Before every move it sends the whole position (position startpos moves e2e4 e7e5 ...) and says go. The engine remembers nothing it needs between moves; it’s a function from position to move.

XBoard, also called CECP (the Chess Engine Communication Protocol), is older (it grew out of the GNU Chess interface in the early 1990s) and works the other way round. The engine keeps the game. The GUI sends one move at a time, and the engine decides by itself when it’s its turn:

> new                 start a game; the engine plays Black
> level 40 5 0        40 moves in 5 minutes
> post                show thinking
> time 30000          the engine's clock: 300.00 seconds (centiseconds)
> otim 30000          the opponent's clock
> usermove e2e4       the opponent's move: now it's the engine's turn
< 1 -15 0 21 e7e5     thinking: depth, score, time, nodes, PV
< ...
< move e7e5

So an XBoard engine is a small state machine. It holds the position and the moves that led to it (for undo and remove, which take back one or two plies). It also tracks which side it plays: new makes it Black, go makes it play the side to move and start thinking, and force makes it play neither, so the GUI can enter moves from a game. Finally it holds the clock settings: level, st (seconds per move), sd (maximum depth), and the clock readings time/otim sent before each move.

cb2026 keeps all of that in one Session, and turns it into the same Limits the UCI side builds, so both protocols drive exactly the same search through the Engine API.

Thinking output

With post, every completed iteration prints a line in the format XBoard expects:

/// A thinking line: `depth score time nodes pv`, score in centipawns (mates as 100000 + moves),
/// time in centiseconds, the PV in coordinate notation.
fn thinking_line(info: &SearchInfo, chess960: bool) -> String {
    let score = match info.score {
        Score::Centipawns(cp) => cp,
        Score::Mate(moves) if moves > 0 => 100_000 + moves,
        Score::Mate(moves) => -100_000 + moves,
    };
    let pv: Vec<String> = info.pv.iter().map(|&mv| move_text(mv, chess960)).collect();
    format!(
        "{} {score} {} {} {}",
        info.depth,
        info.time.as_millis() / 10,
        info.nodes,
        pv.join(" ")
    )
}

Scores are in centipawns from the engine’s side. Mates use a convention: 100000 plus the number of moves to mate, negative when being mated, so a GUI can show “#3”.

With MultiPV, each depth has several lines, and the order matters. XBoard sorts a depth’s lines by score to show them. But it keeps the last line it reads as the engine’s score and PV, which go into the saved game, the evaluation graph and adjudication. So cb2026 holds a depth’s lines until the last one arrives, then sends them best last: that’s the expected count and flush in the search thread below. Before 1.0.0 it sent only the best line, which the GUI matrix found (#170).

Feature negotiation

XBoard learns what an engine supports from feature lines sent in answer to protover 2. Some features change the protocol itself:

  • usermove=1: moves arrive as usermove e2e4, not as bare e2e4, so they can’t be confused with commands.
  • setboard=1: positions arrive as FEN, not through the old edit mode.
  • san=0: moves are in coordinate notation.
  • ping=1: ping N is answered with pong N, so the GUI can tell when the engine has caught up.

Others announce abilities: analyze, memory (hash size), smp (threads), egt="syzygy" (tablebases) and variants="normal,fischerandom" (Chess960). In Chess960, XBoard writes castling as O-O and O-O-O, because “king to g1” is ambiguous when the king may start on g1.

Pitfalls

  • The race after move. As soon as the engine prints move e7e5, the GUI may answer with the next move. If the engine’s own board hasn’t played e7e5 yet, the reply looks illegal. The search thread records the move where the session can see it before printing it, and the session plays any recorded moves before it handles the next command.
  • Commands while thinking. force, new, undo or result can arrive mid-search. The search is stopped without moving: the GUI no longer wants a move. ? is different. It means “move now”, so the search stops and plays its best move so far.
  • Clocks in centiseconds, level times in minutes or minutes:seconds, and st in seconds. Three units for one idea.

Pondering

A chess clock only runs on your own turn, but nothing stops a program from thinking during the opponent’s. Pondering guesses the opponent’s reply (the second move of the principal variation) and searches the position after it while the opponent thinks. If the guess is right (a ponder hit), that search has a head start, often of several plies. If it’s wrong, it’s thrown away, and nothing is lost but electricity.

Strong engines guess right about half the time, so pondering is worth real strength in games against opponents who think. It’s no use in self-play tests (both engines would share the cores), which is why the match tool leaves it off.

In UCI

The GUI manages pondering. After bestmove e7e5 ponder g1f3 it sends position ... e7e5 g1f3 and go ponder wtime .... The engine searches with no time limit until either ponderhit (the opponent played g1f3: switch to the real clock) or stop (they didn’t: report a move, which the GUI ignores, and wait for a new position):

        // Pondering: think on the opponent's time, with no time limit, until the GUI says
        // `ponderhit` (the opponent played the expected move: switch to the real clock) or
        // `stop` (it didn't). The move must not be reported before either. The search keeps
        // its depth, node and mate limits (`Limits::pondering`), and isn't `infinite`, which
        // means analysis: at a strength level that would leave out the level's horizon.
        let pondering = args.contains(&"ponder");
        let ponder_flag = Arc::new(AtomicBool::new(pondering));
        let ponder = pondering.then(|| {
            (
                limits.clone(),
                engine.time.clone(),
                Arc::clone(&ponder_flag),
            )
        });
        let limits = if pondering {
            limits.pondering()
        } else {
            limits
        };
        let infinite = limits.infinite;

The search keeps its other limits. Limits::pondering drops the clock and keeps the depth, node and mate limits; with none of those it searches as deep as it can:

    /// What a ponder search searches with until its hit: these limits' depth, node and mate
    /// limits, but no time, since a ponder search's time and clock only count from the hit
    /// (the opponent's time is free). A depth or node limit it reaches before the hit ends the
    /// search early and it waits: the hit would end it at once anyway. With no such limit it
    /// searches as deep as it can.
    ///
    /// Not `infinite`, which means analysis: at a strength level, an infinite search leaves out
    /// the level's horizon (see [`levels`]), and a pondered move would be stronger than the
    /// level. The caller still has to wait for the hit before it answers, since the search may
    /// end early. UCI, XBoard and the Chess Node Protocol all ponder with it.
    pub fn pondering(&self) -> Limits {
        let mut pondering = Limits {
            movetime: None,
            time: [None; 2],
            increment: [None; 2],
            moves_to_go: None,
            ..self.clone()
        };
        if !pondering.infinite
            && pondering.depth.is_none()
            && pondering.nodes.is_none()
            && pondering.mate.is_none()
        {
            pondering.depth = Some(MAX_PLY);
        }
        pondering
    }

It would be simpler to make the ponder search infinite, and cb2026 did until #241. But infinite means analysis, and at a strength level an infinite search leaves out the level’s horizon (chapter 19): a level that ponders would play every move after a ponder hit without the person’s horizon, and so above its rating. A search that ends early, at a depth limit or at the horizon, just waits for ponderhit; so does one whose move came from the opening book, which a ponder search now consults as any game search does.

On ponderhit a timer starts with the time the search would have been given for this move. The pondering time is a bonus on top, not counted against it. Without a clock, a depth, node or mate limit ends the search by itself.

In XBoard

XBoard leaves pondering to the engine: hard turns it on, easy off. After printing its move, cb2026’s search thread carries on by itself. It plays the expected reply on its own board and searches it with the move’s limits less the clock (Limits::pondering), so sd still holds:

            loop {
                expected.set(multipv.min(position.legal_moves().len()).max(1));
                let stop = Arc::clone(&lock(&thread_shared).stop);
                let result = engine.search(&position, &keys, &limits, &stop, &mut on_info);
                flush();
                // Analysis and pondering wait to be stopped, or for the ponder hit.
                while (analysis || lock(&thread_shared).pondering.is_some())
                    && !stop.load(Ordering::Relaxed)
                {
                    thread::sleep(Duration::from_millis(1));
                }
                let mut shared = lock(&thread_shared);
                // Stopped by the session, during analysis, or a ponder miss: no move.
                if analysis || shared.pondering.is_some() || thread_quit.load(Ordering::SeqCst) {
                    break;
                }
                let Some(mv) = result.best_move else {
                    break;
                };
                // Record the move before announcing it (see `Shared::played`).
                shared.played.push(mv);
                send(&output, &format!("move {}", move_text(mv, chess960)));
                // Ponder on the expected reply, if there is one and the game goes on.
                let Some(reply) = result.ponder_move.filter(|_| ponder) else {
                    break;
                };
                keys.push(position.hash());
                position.make_move(mv);
                keys.push(position.hash());
                position.make_move(reply);
                if position.legal_moves().is_empty() {
                    break;
                }
                shared.pondering = Some(reply);
                shared.stop = Arc::new(AtomicBool::new(false));
                // No time until the hit, but the depth limit (`sd`) stays; and not `infinite`,
                // which means analysis and would leave out a strength level's horizon.
                limits = limits.pondering();
            }

When the opponent’s usermove arrives, the session compares it with the expected move. A hit clears the ponder flag and starts the timer, as in UCI (with sd and no clock, the search ends at that depth by itself), and the same search goes on to produce the next move. A miss stops the thread without a move and starts a fresh search. Each search gets its own stop flag, so a timer left over from one search can never stop the next.

In the Chess Node Protocol

Solrac Chess AI talks to cb2026 through the Chess Node Protocol (CNP, crates/cnp), which ponders as UCI does with JSON messages. A search with "ponder": true has the expected reply already played, and the limits it should keep to once that reply is on the board, the clock included. Until engine/ponderHit it keeps to none of them and doesn’t answer. On a miss the client cancels it and sends the real position.

cb2026’s node searches with Limits::pondering, as UCI and XBoard do. The time is left to the watchdog thread that every CNP search has. The hit records the moment it came, and the watchdog stops the search once the time manager’s budget has passed since then:

    // A ponder search searches with no time; its time counts from the hit, as long as the time
    // manager would give it then (`timeMs`, or its share of the clock). The tree it built while
    // pondering is kept: it is the same search.
    let (limits, after_hit) = if prepared.ponder {
        let side = prepared.position.side_to_move();
        let think = time::budget(&prepared.limits, side, &engine.time).soft;
        (prepared.limits.pondering(), think)
    } else {
        (prepared.limits.clone(), None)
    };
    // The node's maxTimeMs bounds every search, infinite and ponder ones included, from the
    // request; and a ponder search's time runs out `after_hit`.
    let watchdog = {
        let stop = Arc::clone(&flags.stop);
        let finished = Arc::clone(&finished);
        let hit = flags.hit.clone();
        thread::spawn(move || {
            let deadline = Duration::from_millis(MAX_TIME_MS);
            while !finished.load(Ordering::Relaxed) {
                let out_of_time = after_hit
                    .zip(hit.as_ref().and_then(|hit| hit.get()))
                    .is_some_and(|(think, at)| at.elapsed() >= think);
                if start.elapsed() >= deadline || out_of_time {
                    stop.store(true, Ordering::Relaxed);
                    return;
                }
                thread::sleep(WATCHDOG_POLL);
            }
        })
    };

A depth limit reached before the hit doesn’t waste the wait: the search has stopped and is only waiting, so the hit answers at once, as the protocol asks. The answer says how much of its time was pondering (ponderMs), so a client can see what the head start was worth.

Conformance tests: standing in for the GUIs

Every GUI drives engines a little differently, and the differences are where engines break. GUIs send Windows line endings, change options mid-search, skip ucinewgame, give a few milliseconds on the clock, or send stop a microsecond after go. Claude, who writes most of this code, can’t operate a GUI. So crates/cli/tests/conformance.rs starts the real cb2026 binary, plays scripted sessions with exactly those habits, and checks every answer. It runs with the other tests on every change. The manual checklist in docs/gui-compat.md covers what a script can’t: how the engine looks and behaves inside each GUI.

Playing on Lichess

Lichess lets programs play as BOT accounts through its Bot API. lichess-bot is the usual bridge: it accepts challenges and talks UCI to the engine. docs/lichess.md has the setup: the BOT account, the config, and the settings that matter. Two of those settings are worth explaining here:

  • Move overhead. The clock runs on Lichess’s server. A move that the engine makes with 50 ms to spare can arrive after its time has run out, so the engine keeps a margin per move (the Move Overhead option, 300 ms suggested online).
  • One game at a time, with all threads. Two games at once would split the cores and make both weaker.

What it’s worth

At full strength the search is unchanged (the bench is unchanged). Pondering’s value depends on the opponent and the time control; it isn’t measured in the self-play tests, which run without it. The GUI compatibility matrix is checked on the test Mac before the 1.0 release.

Further reading