Skip to content

Engine Protocol & UCI Output

ChessCore defines an engine abstraction that is independent of any concrete engine, and it parses generic UCI info and bestmove output.

Parsing UCI output

UCIOutputParser converts engine output lines into a structured UCIInfo. The parser conforms to the UCI protocol and does not depend on any Stockfish-specific behavior.

public struct UCIInfo: Sendable, Equatable {
    public var depth: Int?
    public var multipv: Int?
    public var scoreCp: Int?
    public var mateIn: Int?
    public var nps: Int?
    public var pv: [String]      // UCI moves

    // Computed API
    public var score: Score?        // nil when no score was reported
    public var multiPV: Int         // multipv ?? 1
    public var centipawns: Int?     // mate maps to ±100_000 (engine-POV)
    public var bestMoveUCI: String? // pv.first
    public var displayText: String? // "+1.3", "M5", "-M3"

    // White-POV conversion
    public func whitePovCentipawns(sideToMoveIsWhite: Bool) -> Int?
    public static func whitePovCp(_ cp: Int, sideToMoveIsWhite: Bool) -> Int
    public static func whitePovMate(_ mate: Int, sideToMoveIsWhite: Bool) -> Int

    public enum Score: Sendable, Equatable {
        case cp(Int)             // centipawns
        case mate(Int)           // mate in N

        public var centipawns: Int          // mate maps to ±100_000
        public var displayText: String      // "+1.5", "M3", "-M2"
        public var negated: Score
    }
}
if let info = UCIOutputParser.parseInfo(
    "info depth 20 score cp 31 multipv 1 pv e2e4 e7e5 g1f3"
) {
    if let score = info.score {
        print(info.depth, score.displayText)   // Optional(20)  "+0.3"
    }

    // Render the PV in SAN:
    let san = UCIParser.convertPVToSAN(info.pv, from: position)
}

let best = UCIOutputParser.parseBestMove("bestmove e2e4 ponder e7e5")   // "e2e4"

// Distil a MultiPV batch. The result maps each MultiPV index (1 = best)
// to the deepest UCIInfo seen for that rank. Equal-depth updates prefer the
// latest line; lines without a multipv field count as rank 1.
let byRank: [Int: UCIInfo] = UCIOutputParser.bestInfoByRank(collectedInfos)

// parse(_:) is a concise alias for parseInfo(_:):
let info2 = UCIOutputParser.parse(line)

Bounded scores

parseInfo returns nil for lines carrying lowerbound / upperbound — those are partial results from an unresolved aspiration window and should not drive the eval bar or move arrows.

White-POV scores

UCI engines report scores from the perspective of the side to move. To drive an eval bar anchored to White you need to flip the sign for Black's lines:

// Using the instance helper:
if let wpCp = info.whitePovCentipawns(
    sideToMoveIsWhite: position.activeColor == .white
) {
    updateEvaluationBar(centipawns: wpCp)
}

// Or the static helpers for a raw value you already have:
let flipped = UCIInfo.whitePovCp(rawCp, sideToMoveIsWhite: false)     // Black to move
let mateDist = UCIInfo.whitePovMate(rawMate, sideToMoveIsWhite: false)

// displayText is nil on a scoreless info line:
if let text = info.displayText {
    print(text)
}

The ChessEngine protocol

ChessEngine is the abstraction that decouples ChessCore from any specific engine implementation. Conform a type to it to drive SwiftStockfish, a neural engine, or a mock:

public protocol ChessEngine: AnyObject {
    var name: String { get }
    var isReady: Bool { get }
    func analyze(position: Position, topK: Int) async throws -> EngineAnalysis
}

EngineAnalysis

public struct EngineAnalysis: Sendable {
    public let topMoves: [ScoredMove]
    public let evaluation: Evaluation?
    public let depth: Int?

    public struct ScoredMove: Identifiable, Sendable {
        public var id: String { notation }   // SAN is stable across depth updates
        public let move: Move
        public let notation: String
        public let probability: Double?      // policy networks only; nil for search engines
        public let score: UCIInfo.Score?
        public let pvLine: [String]
    }

    public enum Evaluation: Equatable, Sendable {
        case winDrawLoss(win: Double, draw: Double, loss: Double)
        case centipawns(Int)
        case mate(Int)
    }
}

probability is for engines that produce one — a policy network ranking moves by how likely they are to be played. A search engine leaves it nil and ranks by score; there is no invented value to tell apart from a genuine zero.

Evaluation carries the engine's assessment in whichever form the engine natively produces. Formatting it — decimal places, mate spelling, whether a win/draw/loss split becomes a percentage — is the caller's.

ScoredMove.id is the SAN notation — it is unique within one position's move list and stable across depth updates, so list rows keep a stable identity as the engine publishes deeper results.

Engine errors

public enum EngineError: LocalizedError {
    case engineUnavailable      // no engine loaded, or not ready
    case invalidPosition        // position could not be encoded for the engine
    case analysisFailed(String)
    case noLegalMoves
}

Driving a live engine (UCIEngine)

UCIEngine is the low-level transport seam. It carries no chess logic — just a command channel and an ordered output stream. Both SwiftStockfish.StockfishEngine and SwiftReckless.RecklessEngine conform to it; the consuming app declares the conformances (each links its own engine package).

public protocol UCIEngine: AnyObject, Sendable {
    /// Ordered output lines from the engine, without trailing newlines.
    var output: AsyncStream<String> { get }

    /// Send a raw UCI command (no trailing newline needed).
    func send(_ command: String)

    // Convenience shorthands:
    func uci()       // send("uci")
    func isReady()   // send("isready")
    func quit()      // send("quit")
}

A typical adapter loop reads the output stream, pipes each line through UCIOutputParser.parseInfo and UCIOutputParser.parseBestMove, and accumulates UCIInfo values to later convert into an EngineAnalysis via the ChessEngine-level analyze(position:topK:) call:

for await line in engine.output {
    if let info = UCIOutputParser.parseInfo(line) {
        collectedInfos.append(info)
    } else if let bestMove = UCIOutputParser.parseBestMove(line) {
        let byRank = UCIOutputParser.bestInfoByRank(collectedInfos)
        // build EngineAnalysis from byRank …
        break
    }
}

One engine at a time

Some in-process engine implementations (e.g. StockfishEngine) capture process-global stdio while live. Only one UCIEngine instance should be live at a time.

Wiring a real engine

A typical ChessEngine adapter drives SwiftStockfish, sends position.consistentFEN, collects info lines via UCIOutputParser.parseInfo, and constructs ScoredMove values with UCIParser.uciToMove and MoveGenerator.algebraicNotation. See the Usage Examples for a complete implementation.