A chess engine that speaks the UCI protocol, written entirely in Brainfuck. The BF code is generated by a Python compiler (generate.py) that assembles higher-level chess logic into raw ><+-.,[] instructions.
make # builds bfi (interpreter) + chess.bf (~5.6 MB)
printf 'uci\nisready\nposition startpos\ngo\nquit\n' | ./bfi chess.bfOutput:
id name BFChess
id author BFChess
uciok
readyok
bestmove d2d4
- Depth-3 minimax search with alpha-beta pruning and captures-first move ordering
- Full move generation: all piece types, castling (kingside + queenside), en passant, promotion
- MVV-LVA evaluation with positional bonuses (center control, piece activity, pawn advancement, check detection, exchange awareness, anti-repetition)
- Stalemate detection: distinguishes stalemate (draw, score 128) from checkmate at all search depths
- Legality checking via make/unmake with 3-pass attack analysis (king safety, destination safety, check detection)
- UCI protocol:
uci,isready,position startpos,domove,go,perft,quit - Perft validation: 11/11 positions passing (including castling and en passant edge cases)
The Python compiler emits BF code through a BFEmitter that tracks a virtual data pointer, so move_to(cell) emits the minimal number of > or < instructions. Higher-level modules build on this:
| File | Role |
|---|---|
bf_emitter.py |
Core BF emitter with pointer tracking |
bf_memory.py |
Memory layout constants (cell addresses) |
bf_primitives.py |
Control flow: compare_eq, switch_on_value, if_else |
bf_io.py |
Read a line from stdin into a buffer |
bf_chess.py |
Board init, parse UCI position commands, apply moves (castling, EP, promotion) |
bf_movegen.py |
Move generation, evaluation, depth-1/2/3 search, alpha-beta pruning |
bf_uci.py |
UCI protocol loop (dispatch on uci, isready, position, domove, go, perft, quit) |
generate.py |
Entry point: assembles everything into chess.bf |
bfi.c |
BF interpreter with RLE compression and precomputed jump table |
Cells 0-15: Temporaries
Cells 16-22: Game state (side to move, castling rights, king positions, EP file)
Cells 23-28: Movegen hot cells (BEST_FROM, BEST_TO, HAVE_LEGAL, ...)
Cells 30-93: Chess board (64 squares, stride 1)
Cells 94-99: Extra temps near board/buffer boundary
Cells 100-227: Input buffer (128 bytes)
Cells 120-129: Legality workspace (make/unmake, retry loop)
Cells 130-159: Evaluation workspace (scoring, attack detection, exchange, check)
Cells 160-172: Depth-2 search control (D2_BEST_SCORE, D2_OPP_SCORE, ...)
Cells 173-183: Depth-3 search control (D3_BEST_SCORE, D3_OPP_RESULT, ...)
Cells 315-351: Saved outer-loop state for depth-2/3 iterations
Cells 400-471: Depth-2 full state backup (64 board + 8 state cells)
Cells 600-671: Depth-3 full state backup (64 board + 8 state cells)
BF has no random access. Reading board[index] where index is a runtime value requires a 64-way switch: compare the index against 0, 1, 2, ..., 63 and copy from the matching fixed cell. This is the fundamental cost driver.
The engine searches 3 plies deep (our move, opponent's reply, our reply):
- Depth-3 (maximizer): tries each legal move, calls depth-2 for opponent's best reply, picks the move where the opponent's minimax score is highest (best for us)
- Depth-2 (minimizer): tries each legal move, calls depth-1 for our best reply, picks the move where our score is lowest (best for opponent)
- Depth-1 (maximizer): enumerates all legal moves, scores each with MVV-LVA + positional evaluation, picks the highest-scoring move
Alpha-beta pruning and captures-first move ordering reduce the search tree significantly.
Each legal move is scored by _score_move():
| Component | Score |
|---|---|
| Any legal move (base) | 1 |
| Captures (MVV) | |
| Capture pawn | +20 |
| Capture knight | +64 |
| Capture bishop | +66 |
| Capture rook | +100 |
| Capture queen | +180 |
| LVA bonus | |
| Pawn captures | +5 |
| Knight/Bishop captures | +3 |
| Rook captures | +1 |
| Special moves | |
| En passant | +20 |
| Castling | +15 |
| Positional | |
| Non-king piece activity | +3 |
| Inner center (d4/e4/d5/e5) | +3 |
| Knight inner center (extra) | +3 (total +6) |
| Extended center | +1 |
| Development (rank 1/8 exit) | +1 |
| Pawn center push | +2 |
| Pawn on rank 7/2 (near promo) | +20 |
| Pawn on rank 6/3 | +10 |
| Rook on 7th rank | +5 |
| Tactical | |
| Gives check | +15 |
| Check + capture (synergy) | +10 |
| Penalties | |
| Non-capture to attacked square | score = 1 |
| Losing capture (attacker > victim) | score = 2 |
| Reverse of last move (anti-repetition) | score = 1 |
Piece values use standard ratios scaled to fit 8-bit cells (max legal score ~234).
The naive approach — unrolling every board access at compile time — produced a 119 MB BF program. The depth-1 version was optimized down to 943 KB. The current depth-3 version with alpha-beta pruning is ~5.6 MB, with the added size coming from the depth-2 and depth-3 search loops (each requiring full state save/restore of 72 cells).
Instead of 64 copies of the loop body, emit one body that executes 64 times at runtime:
e.set_cell(COUNTER, 64)
e.move_to(COUNTER)
e.emit('[')
# ... one loop body handles all 64 squares ...
e.dec(COUNTER)
e.move_to(COUNTER)
e.emit(']')Impact: 117 MB -> ~1.7 MB (move generation alone)
Knight and king moves have 8 possible offsets each. Instead of 8 separate calls to _try_target (each containing a 64-way board read), use a single runtime loop with an N-way dispatch on the offset index.
Similarly, sliding pieces (bishop, rook, queen) use a direction loop (4 directions) with an inner distance loop (up to 7 steps), stopping on occupied squares.
Impact: 1.7 MB -> 282 KB (move generation)
BF pointer movement (> and <) is O(distance). Moving work cells close to the data they access most often reduces code size:
- Legality workspace (cells 120-129): adjacent to board end (cell 93)
- Direct cell access for castling squares: ~50 bytes vs ~20 KB per
_fast_read_boardcall - Batch buffer reads: read 5 characters in one 128-way pass instead of 5 separate passes
- Values > 128 use decrements from 0 instead of increments (e.g., 254 =
--instead of 254+'s) _divmod8computes file/rank from square index at runtime instead of a 64-way lookup tableoutput_bestmoveuses divmod to convert square indices to algebraic notation (~10 KB vs ~191 KB)
The interpreter uses a 3-phase approach for performance:
- RLE compression: consecutive
><+-collapsed into single instructions with a count (5.6 MB raw -> ~700K instructions, ~8x compression) - Jump table: precomputed bracket pairs on the compressed instruction array for O(1) loop jumps
- Execution: 64 KB tape, 8-bit wrapping cells
This gives roughly 3-5x speedup over naive character-by-character interpretation.
python3 play.py # Play as white against the engine (displays board)# Tournament against Stockfish (calibrated Elo, CCRL 40/4 time control)
python3 play_stockfish.py --elo 1320 --games 10 --both-sides --pgn games.pgn -v
# Tournament against Stockfish (fixed depth, legacy mode)
python3 play_stockfish.py --depth 1 --games 10 --both-sides --pgn games.pgn -v
# Tournament against random moves (baseline)
python3 play_random.py --games 10 --both-sides --seed 42 --pgn random.pgn -v
# Automated game (scripted white vs engine)
python3 auto_play.pyWhen --elo is specified, Stockfish uses UCI_LimitStrength=true with a simulated 120+1s game clock (the time control Stockfish's UCI_Elo scale was calibrated against, anchored to CCRL 40/4). BFChess always searches to fixed depth-3 with no time management.
python3 generate.py # Generate chess.bf (~5.6 MB)
python3 test_perft.py # 11/11 perft positions pass
printf 'uci\nisready\nposition startpos\ngo\nquit\n' | ./bfi chess.bf
# -> bestmove d2d4Tested with UCI_LimitStrength=true, UCI_Elo=1320 (Stockfish's minimum), using the CCRL 40/4 calibration time control (120s + 1s increment). Stockfish manages its own clock; BFChess has unlimited time (fixed depth-3).
Results: +0 =0 -10 / 10 (0%)
All 10 losses by checkmate (12-32 moves)
Stockfish never used more than half its clock — it comfortably checkmated BFChess every game. BFChess captures material and controls the center, but 3 plies of lookahead is not enough to see tactical threats that Stockfish exploits.
Tested against an engine that plays uniformly random legal moves (baseline).
Results: +4 =6 -0 / 10 (70%)
4 wins by checkmate, 6 draws by stalemate, 0 losses
BFChess dominates random play in the opening and middlegame, winning all material. However, 60% of games end in stalemate: the engine captures everything and accidentally traps the bare king. The search includes stalemate detection (score 128 = draw at all depths), but stalemate positions that form beyond the 3-ply horizon remain invisible.
| Opponent | Score | Win% |
|---|---|---|
| Random legal moves | +4 =6 -0 | 70% |
| Stockfish (minimum settings) | +0 =0 -10 | 0% |
The sample sizes are small (10 games each) and the win rates extreme (0% and 70%), so a precise Elo estimate is not meaningful. BFChess is clearly stronger than random play but clearly weaker than even the weakest Stockfish configuration.
- No iterative deepening or time management (fixed depth-3)
- Queen-only promotion in search (recognizes opponent underpromotions via
domove) - No opening book or endgame tablebase
- No king safety, pawn structure, or piece-square tables beyond simple center bonuses
- Move time: 45-600+ seconds per move depending on position complexity
- Stalemate blindness beyond the search horizon (major issue vs weak opponents)
Developed by Mathieu Acher and Claude Code (Opus 4.6).