Skip to main content

Communicating with Stockfish from Go: The UCI Protocol

·7 mins
Table of Contents
Stockfish is the world’s strongest chess engine. Communicating with it from Go via the UCI protocol takes about 30 lines. This post shows the real implementation: process spawning, the UCI handshake, FEN input, evaluation parsing, and a complete reusable Engine struct.

The original version of this post called non-existent methods like chess.NewEngine("stockfish"), SetDifficulty, and Evaluate on the notnil/chess library. That library is a chess rules engine – it does not wrap Stockfish at all. Communicating with Stockfish requires the UCI protocol over stdin/stdout, which is exactly what this post covers.

The UCI Protocol
#

UCI (Universal Chess Interface) is a plain-text stdin/stdout protocol. Your program writes commands to Stockfish’s stdin and reads responses from its stdout. There is no shared memory, no library API, no RPC. Just text.

The minimal handshake:

you  → uci
fish → id name Stockfish 16
fish → id author T. Romstad, M. Costalba, J. Kiiski, G. Linscott
fish → uciok
you  → isready
fish → readyok
you  → position fen rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
you  → go depth 20
fish → info depth 1 seldepth 1 multipv 1 score cp 22 nodes 20 nps 20000 time 1 pv e2e4
fish → info depth 20 seldepth 27 multipv 1 score cp 30 nodes 1234567 ...
fish → bestmove e2e4 ponder c7c5

The info lines stream analysis: depth, score in centipawns (100 cp = 1 pawn), and the principal variation (best line). The final bestmove line is the answer.

Setup: Install Stockfish
#

# macOS
brew install stockfish

# Ubuntu/Debian
sudo apt-get install stockfish

# verify
stockfish --version

The Complete Engine Struct
#

engine.go
package chess

import (
    "bufio"
    "fmt"
    "io"
    "os/exec"
    "strconv"
    "strings"
)

type Engine struct {
    cmd    *exec.Cmd
    stdin  io.WriteCloser
    stdout *bufio.Scanner
}

type AnalysisResult struct {
    BestMove string
    Score    int    // centipawns; positive = white advantage
    Depth    int
    PV       string // principal variation (best line)
    Mate     *int   // non-nil if forced mate; value = moves to mate
}

// NewEngine spawns a Stockfish process and completes the UCI handshake.
func NewEngine(path string) (*Engine, error) {
    if path == "" {
        path = "stockfish"
    }

    cmd := exec.Command(path)

    stdin, err := cmd.StdinPipe()
    if err != nil {
        return nil, fmt.Errorf("stdin pipe: %w", err)
    }

    stdoutPipe, err := cmd.StdoutPipe()
    if err != nil {
        return nil, fmt.Errorf("stdout pipe: %w", err)
    }

    if err := cmd.Start(); err != nil {
        return nil, fmt.Errorf("start stockfish: %w", err)
    }

    e := &Engine{
        cmd:    cmd,
        stdin:  stdin,
        stdout: bufio.NewScanner(stdoutPipe),
    }

    if err := e.handshake(); err != nil {
        e.Close()
        return nil, err
    }

    return e, nil
}

func (e *Engine) handshake() error {
    if err := e.send("uci"); err != nil {
        return err
    }
    // Read until "uciok"
    if err := e.readUntil("uciok"); err != nil {
        return fmt.Errorf("uci handshake: %w", err)
    }

    if err := e.send("isready"); err != nil {
        return err
    }
    // Read until "readyok"
    if err := e.readUntil("readyok"); err != nil {
        return fmt.Errorf("isready: %w", err)
    }

    return nil
}

func (e *Engine) send(cmd string) error {
    _, err := fmt.Fprintln(e.stdin, cmd)
    return err
}

func (e *Engine) readUntil(token string) error {
    for e.stdout.Scan() {
        if strings.HasPrefix(e.stdout.Text(), token) {
            return nil
        }
    }
    if err := e.stdout.Err(); err != nil {
        return err
    }
    return fmt.Errorf("EOF before %q", token)
}

Analyzing a Position
#

analysis.go
package chess

import (
    "fmt"
    "strconv"
    "strings"
)

// AnalyzeFEN analyzes the position given in FEN notation.
// depth controls how many half-moves Stockfish searches.
func (e *Engine) AnalyzeFEN(fen string, depth int) (*AnalysisResult, error) {
    if err := e.send(fmt.Sprintf("position fen %s", fen)); err != nil {
        return nil, err
    }
    if err := e.send(fmt.Sprintf("go depth %d", depth)); err != nil {
        return nil, err
    }

    var result AnalysisResult

    for e.stdout.Scan() {
        line := e.stdout.Text()

        if strings.HasPrefix(line, "info") {
            parseInfo(line, &result)
            continue
        }

        if strings.HasPrefix(line, "bestmove") {
            parts := strings.Fields(line)
            if len(parts) >= 2 {
                result.BestMove = parts[1]
            }
            return &result, nil
        }
    }

    return nil, fmt.Errorf("engine closed before bestmove")
}

func parseInfo(line string, r *AnalysisResult) {
    fields := strings.Fields(line)
    for i := 0; i < len(fields)-1; i++ {
        switch fields[i] {
        case "depth":
            if v, err := strconv.Atoi(fields[i+1]); err == nil {
                r.Depth = v
            }
        case "score":
            if i+2 < len(fields) {
                switch fields[i+1] {
                case "cp":
                    if v, err := strconv.Atoi(fields[i+2]); err == nil {
                        r.Score = v
                        r.Mate = nil
                    }
                case "mate":
                    if v, err := strconv.Atoi(fields[i+2]); err == nil {
                        r.Mate = &v
                    }
                }
            }
        case "pv":
            // everything after "pv" is the principal variation
            if i+1 < len(fields) {
                r.PV = strings.Join(fields[i+1:], " ")
            }
        }
    }
}

Depth vs Time Analysis
#

// Search exactly 20 plies deep.
// Deterministic: same position always produces the same result.
result, err := engine.AnalyzeFEN(fen, 20)

Depth-based analysis is reproducible. Use it for position evaluation pipelines, automated testing, and any case where you want consistent results.

// Search for exactly 1000 milliseconds.
// Adapts to hardware: faster machines reach greater depth.
func (e *Engine) AnalyzeFENTime(fen string, ms int) (*AnalysisResult, error) {
    if err := e.send(fmt.Sprintf("position fen %s", fen)); err != nil {
        return nil, err
    }
    if err := e.send(fmt.Sprintf("go movetime %d", ms)); err != nil {
        return nil, err
    }
    // same parsing loop as AnalyzeFEN
    return e.readBestMove()
}

Time-based analysis is better for interactive applications where you want a consistent user experience regardless of hardware. Use go movetime 1000 for 1 second per move.

Cleanup
#

cleanup.go
package chess

// Close sends "quit" to Stockfish and waits for the process to exit.
// Always call Close() when done, or the Stockfish process becomes an orphan.
func (e *Engine) Close() error {
    _ = e.send("quit")
    return e.cmd.Wait()
}

Use defer engine.Close() immediately after NewEngine returns successfully.

FEN Notation with notnil/chess
#

FEN (Forsyth-Edwards Notation) is the standard way to represent a chess position as a string. The notnil/chess library generates correct FEN from game state – this is what the library is actually useful for:

fen_example.go
package main

import (
    "fmt"
    "log"

    "github.com/notnil/chess"
)

func main() {
    game := chess.NewGame()

    // Make moves from algebraic notation
    moves := []string{"e4", "c5", "Nf3", "d6", "d4", "cxd4"}
    for _, san := range moves {
        if err := game.MoveStr(san); err != nil {
            log.Fatalf("invalid move %s: %v", san, err)
        }
    }

    // Get FEN for the current position
    fen := game.Position().String()
    fmt.Println(fen)
    // Output: r1bqkbnr/pp2pppp/3p4/8/3pP3/5N2/PPP2PPP/RNBQKB1R w KQkq - 0 4

    // Feed to Stockfish
    engine, err := NewEngine("")
    if err != nil {
        log.Fatal(err)
    }
    defer engine.Close()

    result, err := engine.AnalyzeFEN(fen, 20)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("best move: %s\n", result.BestMove)
    fmt.Printf("score:     %+d cp\n", result.Score)
    fmt.Printf("depth:     %d\n", result.Depth)
    fmt.Printf("pv:        %s\n", result.PV)
}

notnil/chess handles rules and FEN generation. Stockfish handles analysis. The two libraries do not interact directly – your code is the glue.

Common mistakes and how to avoid them

Not reading stdout continuously (deadlock) Stockfish writes its output to a pipe buffer. If you do not read from the pipe, the buffer fills up and Stockfish blocks trying to write. This causes a deadlock: your code waits for Stockfish to finish, and Stockfish waits for your code to read. Always have a goroutine or loop actively draining stdout. The bufio.Scanner loop in AnalyzeFEN handles this correctly.

Not handling “info” lines before “bestmove” Stockfish emits many info lines before the final bestmove. If you read only until the first bestmove-like line and stop, you miss the final score at the deepest depth. The parsing loop must continue reading info lines and update the result until it sees bestmove.

Not sending “quit” on shutdown If you kill the Stockfish process without sending “quit” first, the process may leave temporary files or fail to flush output. More importantly, calling cmd.Wait() without quit may block indefinitely. Always send “quit” before waiting for the process.

Using fake library methods The notnil/chess library does not expose Stockfish. It is a chess rules engine for representing positions, validating moves, and generating FEN. Do not look for NewEngine, SetDifficulty, or Evaluate in that package. Use exec.Command and UCI as shown in this post.

Hardcoding the Stockfish path Pass the path as a parameter or read it from an environment variable. On different systems Stockfish may live at /usr/bin/stockfish, /opt/homebrew/bin/stockfish, or a custom path. Defaulting to "stockfish" (which relies on PATH) is acceptable for development; use an explicit path in production containers.


If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.

Related