Build a Chess Engine#
Prerequisites: Familiarity with Native Compilation basics --
na {}blocks,.na.jacfiles, andwith entry {}.
In this tutorial you will explore a fully playable chess engine written in idiomatic Jac, run it on the native compilation pathway, and compile it to a standalone binary. Along the way you will learn:
- How
jac run --autonativeauto-promotes compatible.jacfiles to native execution - How
jac nacompileproduces a standalone binary with no external toolchain - How
sys.argvenables command-line argument parsing in native programs - How declaration/implementation separation keeps large native programs organized
The full source lives at jac/examples/chess/:
chess.jac-- declarations (types, signatures, entry point)chess.impl.jac-- all method implementations
What You Will Build#
An interactive terminal chess game with:
- A full 8x8 board with all standard pieces
- Legal move generation and validation
- Check, checkmate, and stalemate detection
- Castling, en passant, and pawn promotion
- Position evaluation with center-control and development bonuses
- A
--benchmarkmode that plays automated random games - Command-line argument parsing via
sys.argv
Step 1: Run It#
Before diving into the code, try running the chess engine. You have three options, each demonstrating a different part of the native pathway.
Option A: Standard Jac Execution#
This runs on the Python backend -- full compatibility, standard execution.
Option B: Auto-Native Promotion#
The --autonative flag tells the compiler to analyze the program and, if it only uses native-compatible features (no walkers, async, lambdas, PyPI imports, etc.), automatically promote it to native execution. The chess engine is fully native-compatible, so the compiler JIT-compiles it to machine code and runs it natively -- same .jac file, no changes needed.
How auto-promotion works
The NativeCompatCheckPass walks the AST and verifies the program uses only native-supported constructs. If it passes, the compiler generates LLVM IR, JIT-compiles it, and executes natively. If it fails (e.g., the file uses walkers or by llm()), execution falls back to Python transparently.
Option C: Standalone Binary#
This compiles the .jac file to a self-contained binary. No Python runtime, no external compiler, no external linker -- the entire toolchain runs within Jac itself. The output is a native executable you can distribute and run anywhere.
Benchmark Mode#
The chess engine accepts command-line arguments via sys.argv. Pass --benchmark (or -b) to run automated random games instead of interactive play:
# With autonative
jac run --autonative jac/examples/chess/chess.jac -- --benchmark
# With compiled binary
jac nacompile jac/examples/chess/chess.jac -o chess
./chess --benchmark
Running 10 games...
Game 1: Black wins
Game 2: White wins
Game 3: Draw
...
--- Results (10 games) ---
White wins: 4
Black wins: 3
Draws: 3
Step 2: Project Structure#
The chess engine uses Jac's declaration/implementation separation -- the same pattern as header files in C, but built into the language. This keeps the architecture scannable at a glance:
chess/
├── chess.jac # Declarations: types, signatures, entry point
└── chess.impl.jac # Implementations: all method bodies
The declaration file defines what exists. The implementation file defines how it works. The compiler links them together automatically.
Step 3: Declarations (chess.jac)#
Enums and Constants#
The declaration file starts with enums and global constants:
enum Color { WHITE = 0, BLACK = 1 }
enum PieceKind { PAWN = 0, KNIGHT = 1, BISHOP = 2, ROOK = 3, QUEEN = 4, KING = 5 }
# Castling rights as bit flags
glob CASTLE_WK: int = 0x01,
CASTLE_WQ: int = 0x02,
CASTLE_BK: int = 0x04,
CASTLE_BQ: int = 0x08,
CASTLE_ALL: int = 0x0F;
glob CENTER_MASK: int = 0b00111100; # Bitmask for center files
glob BOARD_SIZE: int = 8;
Global dictionaries map piece kinds to display symbols and material values:
glob WHITE_SYMBOLS: dict[PieceKind, str] = {
PieceKind.PAWN: "P", PieceKind.KNIGHT: "N", PieceKind.BISHOP: "B",
PieceKind.ROOK: "R", PieceKind.QUEEN: "Q", PieceKind.KING: "K"
};
glob PIECE_VALUES: dict[PieceKind, int] = {
PieceKind.PAWN: 100, PieceKind.KNIGHT: 320, PieceKind.BISHOP: 330,
PieceKind.ROOK: 500, PieceKind.QUEEN: 900, PieceKind.KING: 20000
};
Native features: enums, global variables, hex/binary literals, dict literals with enum keys.
Forward Declarations and Function Signatures#
Forward declarations let types reference each other before they are fully defined:
obj Board;
obj Piece;
def opposite_color(color: Color) -> Color;
def to_algebraic(pos: tuple[int, int]) -> str;
def create_piece(kind: PieceKind, color: Color, row: int, col: int) -> Piece;
The Piece Hierarchy#
The type hierarchy is declared with clean signatures -- no method bodies:
obj Piece {
has color: Color,
kind: PieceKind,
pos: tuple[int, int],
has_moved: bool = False;
def row -> int;
def col -> int;
def set_pos(row: int, col: int) -> None;
def symbol -> str;
def raw_moves(board: Board) -> list[Move];
def slide_moves(board: Board, directions: list[tuple[int, int]]) -> list[Move];
}
obj Pawn(Piece) { override def raw_moves(board: Board) -> list[Move]; }
obj Knight(Piece) { override def raw_moves(board: Board) -> list[Move]; }
obj Bishop(Piece) { override def raw_moves(board: Board) -> list[Move]; }
obj Rook(Piece) { override def raw_moves(board: Board) -> list[Move]; }
obj Queen(Piece) { override def raw_moves(board: Board) -> list[Move]; }
obj King(Piece) { override def raw_moves(board: Board) -> list[Move]; }
Each subclass uses override to declare that it replaces the base raw_moves(). The compiler generates vtables so the correct implementation is called based on the object's actual type at runtime.
Native features: single inheritance, virtual dispatch via vtables, forward declarations, union types (Piece | None), tuples.
The Entry Point with sys.argv#
The entry point parses command-line arguments to choose between interactive play and benchmark mode:
import sys;
glob game = Game();
with entry {
_benchmark_mode: int = 0;
_args = sys.argv;
for i in range(1, len(_args)) {
arg = _args[i];
if arg == "--benchmark" or arg == "-b" {
_benchmark_mode = 10;
}
}
if _benchmark_mode > 0 {
game.benchmark(_benchmark_mode);
} else {
game.play();
}
}
sys.argv is a list[str] where argv[0] is the program/binary name. This works identically whether you run with jac run --autonative or as a compiled binary.
Native features: import sys, sys.argv, command-line argument parsing, string comparison.
Step 4: Implementations (chess.impl.jac)#
All method bodies live in the impl file. A few highlights:
Sliding Piece Movement#
The base Piece provides a slide_moves() method used by Bishop, Rook, and Queen:
impl Piece.slide_moves(board: Board, directions: list[tuple[int, int]]) -> list[Move] {
moves: list[Move] = [];
for d in directions {
(dr, dc) = d; # Tuple unpacking
r = self.row() + dr;
c = self.col() + dc;
while board.valid_pos(r, c) {
target: Piece | None = board.at(r, c);
if target is None {
moves.append(Move(from_pos=self.pos, to_pos=(r, c)));
} elif target.color != self.color {
moves.append(Move(from_pos=self.pos, to_pos=(r, c)));
break;
} else {
break;
}
r += dr;
c += dc;
}
}
return moves;
}
Each sliding piece just passes its directions:
impl Bishop.raw_moves(board: Board) -> list[Move] {
return self.slide_moves(board, [(-1, -1), (-1, 1), (1, -1), (1, 1)]);
}
impl Rook.raw_moves(board: Board) -> list[Move] {
return self.slide_moves(board, [(-1, 0), (1, 0), (0, -1), (0, 1)]);
}
King Move Generation with Comprehensions#
The King uses a list comprehension with nested loops and a filter:
impl King.raw_moves(board: Board) -> list[Move] {
moves: list[Move] = [];
row = self.row();
col = self.col();
adjacent: list[tuple[int, int]] = [
(row + dr, col + dc)
for dr in [-1, 0, 1]
for dc in [-1, 0, 1]
if not (dr == 0 and dc == 0)
];
for pos in adjacent {
(r, c) = pos;
if board.valid_pos(r, c) {
target: Piece | None = board.at(r, c);
if target is None or target.color != self.color {
moves.append(Move(from_pos=self.pos, to_pos=pos));
}
}
}
return moves;
}
Board Initialization with postinit#
The board uses nested list comprehensions and a postinit hook:
impl Board.postinit {
self.squares = [[None for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)];
self.setup_pieces();
}
Set Operations for Attack Detection#
Attacked squares are computed using set comprehensions and set union:
impl Board.attacked_squares(by_color: Color) -> set[tuple[int, int]] {
attacked: set[tuple[int, int]] = set();
for piece in self.pieces_of(by_color) {
attacked |= {m.to_pos for m in piece.raw_moves(self)};
}
return attacked;
}
Bitwise Operations for Castling#
Castling rights use bitwise flags -- AND, OR, NOT:
# King moved -- remove both castling rights for that side
if piece.kind == PieceKind.KING {
if piece.color == Color.WHITE {
self.castling_rights &= ~(CASTLE_WK | CASTLE_WQ);
} else {
self.castling_rights &= ~(CASTLE_BK | CASTLE_BQ);
}
}
Position Evaluation#
The evaluator uses material values, center-control bonuses, and development bonuses:
impl Board.evaluate(color: Color) -> int {
score = 0;
for r in range(BOARD_SIZE) {
for c in range(BOARD_SIZE) {
piece: Piece | None = self.squares[r][c];
if piece is not None {
base = PIECE_VALUES[piece.kind];
center_bonus = 0;
if (1 << c & CENTER_MASK) != 0 and 2 <= r and r <= 5 {
center_bonus = base // 10;
}
dev_bonus = 8 if piece.has_moved and piece.kind != PieceKind.KING else 0;
total = base + center_bonus + dev_bonus;
if piece.color == color {
score += total;
} else {
score -= total;
}
}
}
}
return score;
}
Benchmark Mode#
The benchmark method runs N automated games and reports results:
impl Game.benchmark(num_games: int) -> None {
white_wins = 0;
black_wins = 0;
draws = 0;
print(f"Running {num_games} games...\n");
for i in range(num_games) {
g = Game();
seed_random(i * 7919 + 42);
result = g.play_auto();
if result == "White" { white_wins += 1; }
elif result == "Black" { black_wins += 1; }
else { draws += 1; }
print(f"Game {i + 1}: {result} wins" if result != "Draw" else f"Game {i + 1}: Draw");
}
print(f"\n--- Results ({num_games} games) ---");
print(f"White wins: {white_wins}");
print(f"Black wins: {black_wins}");
print(f"Draws: {draws}");
}
Step 5: Three Ways to Run#
This chess engine demonstrates an important property of Jac's native pathway: the same .jac source file works across all three execution modes.
1. Python Backend (default)#
Uses the standard Python runtime. Full compatibility, familiar debugging tools.
2. Auto-Native (--autonative)#
The compiler analyzes the program at build time. If all code is native-compatible, it JIT-compiles to machine code and runs natively. If not, it falls back to Python. No code changes required -- the same .jac file works either way.
This is ideal during development: write normal Jac, add --autonative when you want native speed.
3. Standalone Binary (nacompile)#
Produces a self-contained executable. No Python needed at runtime, no external compiler or linker in the build process. The binary includes everything from the LLVM-compiled machine code to the final ELF/Mach-O packaging.
Feature Recap#
This program exercises a wide range of native Jac features:
| Feature | Where Used |
|---|---|
| Declaration/impl separation | chess.jac / chess.impl.jac |
| Enums | Color, PieceKind |
| Global variables and dicts | PIECE_VALUES, WHITE_SYMBOLS, castling flags |
| Hex/binary literals | 0x01, 0b00111100 |
| Objects with fields and methods | Move, Piece, Board, Game |
| Single inheritance + vtables | Pawn(Piece), Knight(Piece), etc. |
| Forward declarations | obj Board; before Piece references it |
| Tuples and unpacking | (row, col) = pos; |
| Lists and nested lists | squares: list[list[Piece \| None]] |
| List comprehensions | Legal move filtering, adjacent squares |
| Dict comprehensions | piece_map() position lookup |
Set comprehensions and \|= |
attacked_squares() |
| Union types | Piece \| None |
| Ternary expressions | direction = -1 if WHITE else 1 |
| Bitwise operations | Castling rights: &, \|, ~, << |
| Augmented assignment | +=, -=, &=, \|= |
| F-strings | Board display, move notation |
| String methods | strip(), split(), ord(), chr() |
postinit hook |
Board and Game initialization |
import sys / sys.argv |
--benchmark flag parsing |
input() |
Interactive move entry |
Next Steps#
- Add a minimax AI opponent using the existing
evaluate()method - Try the benchmark with
./chess --benchmarkand compare Python vs native speed - Explore C Library Interop to add a graphical interface using a library like raylib
- Read the full source:
chess.jacandchess.impl.jac