Implementing the UCI Protocol in Plain Java (No Frameworks)

The engine was finished, in the sense that mattered to me. Perft agreed with the published numbers, alpha-beta returned the right move, the evaluation had piece-square tables and a taper. In my own harness it happily searched to depth 8 and printed a move. Then I pointed Arena 3.5.1 at the jar and Arena greyed the engine out and told me it was not responding.

No exception. No stack trace. The Java process was alive, sitting at 0% CPU, waiting. It took me longer than I want to admit to work out that the engine had done its job perfectly and just never said anything out loud.

Eight posts in, this was the first one where the thing I was building wasn't chess. It was a text protocol. And the reason I started this project was to work on problems where correctness is checkable, so it's fitting that the layer with no algorithms in it produced the most failures per line of anything I'd written so far.

Table of Contents

The Protocol Is Smaller Than You Think

UCI is lines of ASCII in on stdin, lines of ASCII out on stdout. That's the whole transport. No handshake bytes, no framing, no length prefixes, no binary. If you can write a while loop over readLine() you already have the networking layer.

The design decision that makes it pleasant is that the GUI owns the game. Your engine does not track whose turn it is across moves, does not run a clock, does not know the move history unless it's told. Every time the GUI wants a move it hands you the position from scratch and the clock readings as arguments. That means a whole class of state-desync bugs cannot happen to you.

CECP, the older WinBoard protocol, works the other way. The engine keeps the game state, ticks its own clock, negotiates capabilities with feature lines, and gets moves fed one at a time. It's more conversational and there's more to keep in sync. Having implemented one of the two, I'd start with UCI every time.

The startup exchange is four lines of thinking. The GUI writes uci. You reply with id name, id author, one option line per setting you support, and finally uciok. Nothing before uciok is optional in practice, because Arena parses the options block to build its config dialog, and an engine that sends uciok with no options is simply an engine with no settings.

void handleUci() {
    out.println("id name Kestrel 0.9");
    out.println("id author Vijay");
    out.println("option name Hash type spin default 64 min 1 max 1024");
    out.println("option name Threads type spin default 1 min 1 max 1");
    out.println("option name Ponder type check default false");
    out.println("option name UCI_Chess960 type check default false");
    out.println("uciok");
}

Then there's isready, which is where people's mental model goes wrong. It is not a startup-only handshake. It's a barrier the GUI can throw at you at literally any moment: after setoption, before go, in the middle of a search, while you're pondering. The contract is that you answer readyok as soon as you can, and critically, you answer it even while searching. If the only place you handle isready is inside your startup code, you will deadlock the first time a GUI probes you mid-game.

The Read Loop and the Dispatch Switch

A BufferedReader over System.in, split on whitespace, dispatch on token zero. Java 21's switch on strings keeps this readable.

import java.io.BufferedReader;
import java.io.InputStreamReader;

void run() throws IOException {
    var in = new BufferedReader(new InputStreamReader(System.in));
    String line;
    while ((line = in.readLine()) != null) {
        String[] t = line.trim().split("\\s+");
        switch (t[0]) {
            case "uci"        -> handleUci();
            case "isready"    -> out.println("readyok");
            case "setoption"  -> setOption(t);
            case "ucinewgame" -> newGame();
            case "position"   -> setPosition(t);
            case "go"         -> startSearch(t);
            case "stop"       -> stopFlag.set(true);
            case "ponderhit"  -> ponderHit();
            case "quit"       -> { shutdown(); return; }
            default           -> { /* unknown command: ignore, keep reading */ }
        }
    }
}

That default branch is the single most important line in the file. The specification is explicit that an engine receiving something it doesn't understand should just ignore it and carry on, and in Stefan Meyer-Kahlen's original document the rule extends to tokens inside commands you do know: "if the engine or the GUI receives an unknown command or token it should just ignore it and try to parse the rest of the string."

This is not politeness. Real GUIs send things you haven't implemented. BanksiaGUI probes with debug on. Some tools send go with a searchmoves list. If you throw on an unrecognised token, or worse, exit, the engine dies during setup for reasons that look like nothing. So every parser I wrote walks the token array with an index and skips what it doesn't recognise, rather than asserting positions.

position, and Why I Rebuild the Board Every Time

Two forms. position startpos moves e2e4 e7e5, or position fen <six fields> moves ..., with the moves section optional in both. The FEN is six space-separated fields, which means you cannot naively grab token 2 and call it the FEN.

void setPosition(String[] t) {
    int i = 1;
    if (t[i].equals("startpos")) {
        board = Board.startPosition();
        i++;
    } else if (t[i].equals("fen")) {
        // FEN is six whitespace-separated fields, not one token
        StringBuilder fen = new StringBuilder();
        for (int f = 0; f < 6 && i + 1 + f < t.length; f++) {
            fen.append(f == 0 ? "" : " ").append(t[i + 1 + f]);
        }
        board = Board.fromFen(fen.toString());
        i += 7;
    }
    if (i < t.length && t[i].equals("moves")) {
        for (int m = i + 1; m < t.length; m++) {
            board.makeMove(parseUciMove(board, t[m]));
        }
    }
}

Note that both branches feed the same FEN reader and the same make/unmake path built back when I was getting en passant and castling rights right. The castling-rights field and the en passant square in that FEN are not decoration; a GUI resuming an analysis position will hand you a FEN where the en passant capture is the only good move, and if your parser ignores field four you will produce a move that isn't legal.

The move list grows by one on every ply, so by move 40 you're replaying eighty moves to set up a position you were already sitting on. My instinct was to detect that the incoming line extends the previous one and just apply the new move. I didn't, and I'm glad. Rebuilding costs about 90 microseconds on move 40, which is nothing against a search budget measured in seconds, and it makes every position command independent of every command before it. Incremental application means one missed edge case, a takeback in analysis mode, a GUI that resends the game after a setoption, and your board silently diverges from the GUI's. Cheap, dumb and stateless beat clever here.

The replay is also, quietly, a make/unmake stress test. If your engine survives thousands of position startpos moves ... commands without ever producing an illegal move, the state restoration you proved with perft is still holding.

Four Characters, and the Castling Trap

Long algebraic notation: source square, destination square, and an optional promotion piece. e2e4. e7e8q. The promotion letter is lowercase in the protocol, always, for both colours.

Move parseUciMove(Board board, String s) {
    int from = square(s.charAt(0), s.charAt(1));   // 'e','2' -> 12
    int to   = square(s.charAt(2), s.charAt(3));
    int promo = (s.length() > 4) ? promoType(s.charAt(4)) : NONE;

    // the GUI sends castling as a king move of two files: e1g1, e8c8
    if (board.pieceTypeAt(from) == KING && Math.abs((to & 7) - (from & 7)) == 2) {
        return Move.castle(from, to);
    }
    if (board.pieceTypeAt(from) == PAWN && to == board.enPassantSquare()) {
        return Move.enPassant(from, to);
    }
    return (promo == NONE) ? Move.quiet(from, to) : Move.promotion(from, to, promo);
}

static int promoType(char c) {
    return switch (Character.toLowerCase(c)) {
        case 'n' -> KNIGHT; case 'b' -> BISHOP;
        case 'r' -> ROOK;   default  -> QUEEN;
    };
}

Castling is the trap. Standard UCI encodes it as the king's real start and end squares, so kingside is e1g1 and queenside e1c1. If you decode that as an ordinary king move you get a board with a king on g1 and a rook still sitting on h1, which is not a position and will produce garbage two plies later. Under UCI_Chess960 the convention changes completely: castling becomes king-takes-own-rook, so the same move arrives as e1h1. Both are correct, in their own mode, and the only thing distinguishing them is a flag you were told about at setup time. Even if you never support Chess960, handle the flag so that a GUI which turns it on doesn't quietly feed you moves you decode as king captures its own rook.

Going the other way is the mirror image, and it needs the same awareness:

String toUci(Move m, boolean chess960) {
    int to = m.to();
    if (m.isCastle() && !chess960) to = m.kingDestination();  // g1/c1 form
    if (m.isCastle() && chess960)  to = m.rookSquare();       // e1h1 form
    String s = name(m.from()) + name(to);
    return m.isPromotion() ? s + "pnbrqk".charAt(m.promotionType()) : s;
}

go, and a Time Budget That Isn't Embarrassing

go carries the interesting arguments: wtime, btime, winc, binc, movestogo, depth, nodes, movetime, infinite, ponder. All milliseconds where they're times. All optional. Any combination.

The precedence I settled on: infinite means search until told to stop, movetime means exactly that many milliseconds, depth and nodes mean a hard limit with no clock at all, and if none of those appear you're in a real game and have to budget from the clock yourself.

long budgetMillis(GoParams p, boolean whiteToMove) {
    if (p.movetime > 0) return p.movetime - OVERHEAD;

    long left = whiteToMove ? p.wtime : p.btime;
    long inc  = whiteToMove ? p.winc  : p.binc;
    // no movestogo means sudden death; assume the game has ~30 moves to go
    int togo = (p.movestogo > 0) ? p.movestogo : 30;

    long budget = left / togo + (inc * 3) / 4;
    long ceiling = left / 4;                    // never bet a quarter of the clock
    return Math.max(10, Math.min(budget, ceiling) - OVERHEAD);
}

OVERHEAD is 50 milliseconds in my engine, covering the write, the pipe, and the GUI's own bookkeeping before it stops your clock. That formula is a first pass and I want to be plain about it: it has no concept of position complexity, it doesn't extend when the best move is unstable, and it can't stop early when there's only one legal reply. A real time manager does all three. This one just avoids losing on time, which at this stage is the entire requirement.

The depth and node limits feed straight into the alpha-beta search, and the score it reports comes from the tapered evaluation, so the protocol layer adds no chess knowledge at all. It's a translator.

The Thread That Has to Stay Free

Here is the part that actually decides whether your engine works, and it's the part that took me two evenings.

My first version called the search directly from the dispatch switch. Which is fine, obviously, because the search returns and then you print the move. Except that while the search is running, nothing is calling readLine(). The GUI's stop is sitting in the pipe buffer, unread. Under a fixed depth you get away with it, because the search ends on its own. Under go infinite — which is what every GUI sends the moment a human clicks Analyse — the search never ends, the stop is never read, and the engine is gone. Arena waits, greys it out, and eventually kills the process. That is exactly the "not responding" I opened with.

The fix is one worker thread and one flag.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;

private final ExecutorService worker =
        Executors.newSingleThreadExecutor(r -> {
            Thread t = new Thread(r, "search");
            t.setDaemon(true);          // never block JVM exit on quit
            return t;
        });
private final AtomicBoolean stopFlag = new AtomicBoolean();

void startSearch(String[] tokens) {
    GoParams p = GoParams.parse(tokens);
    stopFlag.set(false);
    Board snapshot = board.copy();     // reader thread keeps mutating `board`
    worker.submit(() -> {
        SearchResult r = search.iterate(snapshot, p, stopFlag);
        out.println("bestmove " + toUci(r.best(), chess960)
                + (r.ponder() != null ? " ponder " + toUci(r.ponder(), chess960) : ""));
    });
}

Single-threaded executor, not a fresh Thread per go, so two searches can never overlap. Daemon thread, so quit doesn't hang the JVM. And the board is copied, because the reader thread will happily process the next position command while the search is still walking the old one.

The flag has to be polled somewhere the search passes constantly. Every node is too often: a volatile read plus a clock check on the hot path measurably costs nodes per second. I check every 2048 nodes, using the node counter that's already there.

int alphaBeta(Board board, int depth, int alpha, int beta) {
    if ((++nodes & 2047) == 0) {
        if (stopFlag.get() || System.currentTimeMillis() >= deadline) {
            aborted = true;            // unwind; do not trust this subtree's score
        }
    }
    if (aborted) return 0;
    // ... normal alpha-beta from here
}

The aborted flag matters as much as the stop flag. A search cut off mid-node has a garbage score, so the iterative deepening driver must discard the incomplete iteration and report the best move from the last finished depth. Report the half-searched one and the engine will occasionally blunder a queen under time pressure, which is a bug that only shows up in real games and is horrible to diagnose after the fact.

Output Discipline

System.out is a PrintStream with autoflush on, but it only flushes on a newline when autoflush is set, and the moment you wrap it in something faster you lose that. My cause of death, on day one, was exactly this:

// dead engine: nothing reaches the GUI until the 8 KB buffer fills
var out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(System.out)));

// alive: the second argument is autoFlush
var out = new PrintWriter(new BufferedWriter(new OutputStreamWriter(System.out)), true);

Note that PrintWriter's autoflush only triggers on println, not on print. Build an info line with print calls and terminate it with a bare print("\n") and you are back to a silent engine.

Then there's info, which is how the engine talks while it thinks. One line per completed depth, at minimum:

void reportIteration(int depth, int seldepth, int score, long nodes,
                     long elapsedMs, List<Move> pv) {
    long nps = elapsedMs > 0 ? nodes * 1000 / elapsedMs : 0;
    StringBuilder sb = new StringBuilder("info");
    sb.append(" depth ").append(depth).append(" seldepth ").append(seldepth);
    // score is ALWAYS from the side-to-move's point of view
    sb.append(isMate(score) ? " score mate " + matePlyToMoves(score)
                            : " score cp " + score);
    sb.append(" nodes ").append(nodes).append(" nps ").append(nps);
    sb.append(" time ").append(elapsedMs);
    sb.append(" hashfull ").append(tt.permill());
    sb.append(" pv");
    for (Move m : pv) sb.append(' ').append(toUci(m, chess960));
    out.println(sb);            // println, so autoflush fires
}

The perspective rule catches everyone once. score cp 40 means the side to move is 40 centipawns better, not White. Since negamax already hands back mover-relative scores, that's free if you don't "helpfully" convert it. Mate scores are in moves, not plies, and negative when you're the one being mated. For long-running searches I also emit info currmove <move> currmovenumber <n> from the root, but only after the first second, since flooding the pipe at depth 3 is pure noise.

And the hard rule: exactly one bestmove per go. Not zero, not two. It must be sent when the search finishes naturally, when the clock expires, and when stop arrives. A stop is not a cancellation; it's an instruction to answer now with whatever you have.

Options, ucinewgame, and quit

Options arrive as setoption name Hash value 128. The name can contain spaces (UCI_Opponent aside, Clear Hash is a common one), so parse by finding the value token and treating everything between name and it as the identifier. Types are spin for bounded integers, check for booleans, combo for a fixed set, string for free text, and button for an action with no value at all.

ucinewgame is a hint that the next position is unrelated to the last, and what it should clear is narrower than it looks. Clear the transposition table and the history and killer heuristics, because they're now describing a different game. Do not clear the options the user set, or reallocate the hash to its default size, or reset the Chess960 flag. I have seen engines drop a user's 512 MB hash setting back to 16 on every new game, and it's always this.

Also: ucinewgame can be expensive, since zeroing a large table takes real time. That's precisely why the GUI follows it with isready and waits. Do the clearing synchronously before you answer readyok and the handshake works exactly as designed.

quit means stop everything and exit, and it can arrive mid-search. Set the stop flag, shut the executor down, return from the read loop. Daemon threads mean a stuck search can't keep the JVM alive, but I still call worker.shutdownNow() so a clean exit is actually clean.

What Broke

The engine that never flushed. Day one, the whole reason this post opens where it does. The buffered writer with no autoflush held uciok until the buffer filled, which never happened. Arena showed "not responding" and I spent an hour reading my handshake logic, which was fine. Worse, once I fixed the handshake but not the bestmove path, I lost a cutechess game on time with the engine sitting at 0% CPU holding a perfectly good move in a buffer.

stop was never read. Searching on the reader thread. Fixed depth worked in every test I wrote, so I shipped it to a real GUI and the first analysis click hung the engine permanently. This is the failure mode I'd warn a friend about before any other, because your unit tests will never see it.

position startpos moves parsed as a FEN. My first parser checked for fen and treated everything else as a FEN string. So startpos went into the FEN reader, which threw, and I caught the exception and ignored it — because the spec says be tolerant, right? The board stayed on whatever position was there before, the moves replayed on top of it, and the engine played moves from a completely different game. Being tolerant of malformed input is not the same as swallowing your own parse failures.

Promotion parsed as uppercase. I wrote case 'Q'. UCI sends lowercase, always, for both colours, so every promotion fell through my switch to the default. The default was queen, which is right about 95% of the time, so this survived hundreds of test games before an underpromotion to a knight came out as a queen and lost a won endgame.

bestmove sent twice. When stop arrived just as the search was finishing naturally, both the stop handler and the search completion printed a move. The GUI took the first as the move and the second as a protocol error mid-game. The fix is that only the search task ever prints bestmove; stop sets the flag and nothing else.

movestogo absent, treated as zero. Sudden-death time control, no movestogo token, my parser left the field at its default of 0, and left / togo threw ArithmeticException inside the worker thread. The exception went to a thread with no handler, the task died silently, no bestmove was ever sent, and the GUI recorded a loss on time. Any uncaught exception on the search thread looks identical to a hang from outside. Wrap the whole task body in a try/catch and, at minimum, emit info string with the message.

Testing It Without a GUI

Three layers, cheapest first. A text file of commands piped straight in catches every parsing and handshake bug in about a second:

$ cat smoke.txt
uci
isready
position startpos moves e2e4 e7e5 g1f3
go depth 6
quit

$ java -jar kestrel.jar < smoke.txt

If uciok, readyok and exactly one bestmove come back, the plumbing works. This one file caught the flush bug, the startpos bug and the double bestmove, and it runs faster than opening a GUI.

Then JUnit, driving the handler directly with no stdin involved. The trick is that the command handler takes a writer rather than reaching for System.out, which makes the whole protocol layer testable in memory:

@Test
void positionWithMovesReplaysFromStartpos() {
    var sink = new StringWriter();
    var uci = new UciEngine(new PrintWriter(sink, true));

    uci.handle("position startpos moves e2e4 e7e5 g1f3 b8c6");
    assertEquals("r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3",
                 uci.currentBoard().toFen());

    uci.handle("go depth 4");
    uci.awaitSearch();
    assertTrue(sink.toString().lines()
                   .filter(l -> l.startsWith("bestmove")).count() == 1);
}

That FEN assertion is doing real work. It proves the replay landed on the position the GUI thinks you're on, including the halfmove clock and the castling rights, which is the same property perft was protecting all along, checked through the protocol layer this time.

Finally, cutechess-cli 1.3.1 for a self-play match against the previous build, which is the only thing that exercises a real clock:

cutechess-cli -engine cmd="java -jar new.jar" -engine cmd="java -jar old.jar" \
  -each proto=uci tc=10+0.1 -rounds 200 -concurrency 4 -pgnout match.pgn

Ten seconds plus a 0.1 second increment is deliberately brutal. If your time management is off by 200 milliseconds you'll flag within thirty games, and cutechess will tell you plainly which engine lost on time rather than leaving you to guess. It's also the only test that ever found my double-bestmove race, because that needs a stop arriving in a window a few milliseconds wide.

What's Next

The protocol work exposed something the harness had been hiding. With a real clock the engine now has to decide when to stop, which means iterative deepening isn't optional any more, and iterative deepening only pays for itself if the previous iteration's best move is searched first at the next depth. That needs somewhere to store it. Which means a transposition table, which means Zobrist hashing, which means an incrementally updated 64-bit key that has to survive make, unmake, en passant and castling without drifting by a single bit.

That's the next post, and I already know how it starts: the hash key doesn't match after unmake, and figuring out which of the four update sites is wrong is going to be an evening I won't enjoy.

Author's note: The failures listed above are ones I actually hit, in roughly the order given, against Arena 3.5.1 and cutechess-cli 1.3.1 on Java 21. The protocol behaviour described here is my reading of the UCI specification and of how those two programs behave in practice; where a GUI does something the spec doesn't require, I've tried to say so rather than present it as the rule.

Frequently Asked Questions

Why does my chess engine show as not responding in Arena?

Almost always output buffering. A PrintWriter wrapped around System.out without autoflush holds your uciok or bestmove line in a buffer until it fills, so the GUI sees silence and gives up. Construct the writer with autoflush enabled and terminate every line with println. The second most common cause is a search running on the reader thread, which means the engine never reads the next command until the search finishes.

What should a UCI engine do with a command it does not recognise?

Ignore it and keep reading. The specification requires this explicitly, for both unknown commands and unknown tokens inside a command it does know. A GUI may prefix a go with tokens your engine has never heard of, and the correct behaviour is to skip past them and parse the rest rather than throwing or refusing the command. This does not mean swallowing exceptions from your own parser when the input was actually valid.

How does a UCI engine encode castling moves?

Under standard UCI, castling is sent as the king's start and end square, so White castling kingside is e1g1 and queenside is e1c1. Under UCI_Chess960 the convention changes to king-takes-own-rook, making the same move e1h1. Parsing e1g1 as a plain two-square king move is the trap: the engine must recognise that a king moving two files from its start square is a castle and produce the internal move that also relocates the rook.

Why does a UCI search need to run on a separate thread?

Because stop, quit and isready arrive on the same input stream while the search is running. If the search runs on the reader thread, nothing reads that input until the search returns, and go infinite never returns. The GUI waits for a bestmove that will never come and eventually kills the process. Run the search on a worker thread and poll a volatile or atomic stop flag inside the node loop, every couple of thousand nodes rather than every node.

How do you allocate time from wtime, btime and movestogo?

A workable first pass is to divide the remaining clock by movestogo, add most of the increment, and cap the result at a quarter of the clock so a bad estimate cannot flag the engine. When movestogo is absent the game is sudden death, and the correct default is a conservative divisor around 30, not zero. Subtract a small overhead, around 50 milliseconds, for process and GUI latency.

What does score cp mean in a UCI info line?

It is the evaluation in centipawns from the point of view of the side to move, not from White's point of view. A positive score means the side about to move is better. Mate scores use score mate followed by the number of moves, not plies, and a negative number means the engine is the one getting mated.

How do you test a UCI engine without a chess GUI?

Pipe a text file of commands into the engine and read the output, which catches handshake and parsing bugs in seconds. Then write JUnit tests that call the command handler directly with a command string and a captured writer, avoiding stdin entirely. Finally run cutechess-cli self-play matches at a short time control, which is the only test that exercises a real clock and finds the timing and threading problems.