|
| 1 | +<!doctype html> |
| 2 | +<html lang="en"> |
| 3 | + |
| 4 | +<head> |
| 5 | + <script src="/header.js"></script> |
| 6 | + <!-- Google tag (gtag.js) --> |
| 7 | + <script async src="https://www.googletagmanager.com/gtag/js?id=G-E58GKHEECC"></script> |
| 8 | + <script> window.dataLayer = window.dataLayer || []; function gtag() { dataLayer.push(arguments); } gtag('js', new Date()); gtag('config', 'G-E58GKHEECC');</script> |
| 9 | + |
| 10 | + <!-- Formulas --> |
| 11 | + <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> |
| 12 | + <script> |
| 13 | + window.MathJax = { |
| 14 | + tex: { |
| 15 | + inlineMath: { '[+]': [['$', '$']] } |
| 16 | + } |
| 17 | + }; |
| 18 | + </script> |
| 19 | + |
| 20 | + <!-- Code display --> |
| 21 | + <link rel="stylesheet" href="https://unpkg.com/@highlightjs/cdn-assets@11.11.1/styles/default.min.css"> |
| 22 | + <script src="https://unpkg.com/@highlightjs/cdn-assets@11.11.1/highlight.min.js"></script> |
| 23 | + <script>hljs.highlightAll();</script> |
| 24 | + <link id="hljs-light" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.8.0/styles/github.min.css"> |
| 25 | + <link id="hljs-dark" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.8.0/styles/atom-one-dark.min.css"> |
| 26 | + |
| 27 | + <!-- Post title automation --> |
| 28 | + <style> |
| 29 | + .post_title { |
| 30 | + position: relative; |
| 31 | + } |
| 32 | + |
| 33 | + /* Icon in the left margin */ |
| 34 | + .post_title a .link-icon { |
| 35 | + position: absolute; |
| 36 | + left: -1.5em; |
| 37 | + top: 50%; |
| 38 | + transform: translateY(-50%); |
| 39 | + opacity: 0; |
| 40 | + } |
| 41 | + |
| 42 | + /* Show icon on hover */ |
| 43 | + .post_title:hover a .link-icon { |
| 44 | + opacity: 1; |
| 45 | + } |
| 46 | + </style> |
| 47 | + <script> |
| 48 | + document.addEventListener("DOMContentLoaded", () => { |
| 49 | + document.querySelectorAll(".post_title").forEach(h => { |
| 50 | + if (h.querySelector("a")) |
| 51 | + return; |
| 52 | + const id = h.id; |
| 53 | + if (!id) |
| 54 | + return; |
| 55 | + |
| 56 | + const text = h.textContent.trim(); |
| 57 | + h.textContent = ""; |
| 58 | + h.classList.add("forehead", "undertow", "charming", "hella", "anchor"); |
| 59 | + |
| 60 | + const a = document.createElement("a"); |
| 61 | + a.className = "relax"; |
| 62 | + a.href = `./#${id}`; |
| 63 | + |
| 64 | + // Add Font Awesome link icon on the left |
| 65 | + const icon = document.createElement("i"); |
| 66 | + icon.className = "fa-solid fa-link link-icon"; |
| 67 | + a.appendChild(icon); |
| 68 | + |
| 69 | + // Add the title text |
| 70 | + const span = document.createElement("span"); |
| 71 | + span.textContent = text; |
| 72 | + a.appendChild(span); |
| 73 | + |
| 74 | + h.appendChild(a); |
| 75 | + }); |
| 76 | + }); |
| 77 | + </script> |
| 78 | + |
| 79 | + <script src="https://kit.fontawesome.com/ddb1b5eb41.js" crossorigin="anonymous"></script> |
| 80 | + |
| 81 | + <meta charset="utf-8"> |
| 82 | + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |
| 83 | + <meta name="author" content="Paul-Elie Pipelin"> |
| 84 | + <meta name="description" content="The Ultimate Chess Programming Book. Personal site of Paul-Elie Pipelin, engineer in digital imaging."> |
| 85 | + <meta name="keywords" content="Chess, Book, Ultimate, Engineer, Paul-Elie, Pipelin, Digital, Imaging, Computer, Graphics"> |
| 86 | + <meta name="theme-color" content="#36a1b0"> |
| 87 | + |
| 88 | + <title>Paul-Élie Pipelin - The Ultimate Chess Programming Book</title> |
| 89 | + <link rel="preconnect" href="https://fonts.googleapis.com"> |
| 90 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| 91 | + <link href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,400;0,550;1,400;1,550&family=Quicksand:wght@400;550&display=swap" rel="stylesheet"> |
| 92 | + <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" integrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous"> |
| 93 | + <link rel="stylesheet" href="/styles.css"> |
| 94 | + <link rel="icon" type="image/x-icon" href="/assets/Torch.png"> |
| 95 | +</head> |
| 96 | + |
| 97 | +<body> |
| 98 | + <a class="finger hella bae-rock +" href="/experiments/chessbook"> |
| 99 | + english |
| 100 | + </a> |
| 101 | + <a class="finger hella bae-rock +" href="/experiments/chessbook"> |
| 102 | + français |
| 103 | + </a> |
| 104 | + |
| 105 | + <header id="header"> |
| 106 | + <h1 class="forehead undertow charming hella ++++"> |
| 107 | + Paul-Élie Pipelin |
| 108 | + </h1> |
| 109 | + </header> |
| 110 | + |
| 111 | + <!------------------------------------------------> |
| 112 | + |
| 113 | + <nav class="tight stormsurge +++"> |
| 114 | + <a class="finger bae-rock hella" href="/">home</a> |
| 115 | + <a class="finger bae-rock hella" href="/experiments"><code>experiments</code></a> |
| 116 | + </nav> |
| 117 | + |
| 118 | + <!------------------------------------------------> |
| 119 | + <div class="book"> |
| 120 | + <h1 class="forehead undertow charming hella ++++" id="title"> |
| 121 | + The Ultimate Chess Programming Book |
| 122 | + </h1> |
| 123 | + This book describes all the elements that a decent chess engine can contain and is written for beginners in <a href="https://www.chessprogramming.org/Main_Page" , target="_blank">chess programming</a>. |
| 124 | + <br> |
| 125 | + Some parts are illustrated with code written in <a href="https://ziglang.org/" target="_blank">Zig</a> often coming from <a class="finger inline bae-swat hella" href="https://github.com/ppipelin/radiance" target="_blank">Radiance</a> engine. Multiple good guides already exists alongside eponymous engines, like <a href="https://rustic-chess.org/" , target="_blank">Rustic</a> or <a href="http://mediocrechess.blogspot.com/" , target="_blank">Mediocre</a> but isn't exhaustive enough in my opinion. |
| 126 | + |
| 127 | + <h2 class="post_title" id="introduction">Introduction</h2> |
| 128 | + Chess is a turn-based board game where all information are known at all time. Position is symmetrical with white color starting. Each piece type moves a certain way. |
| 129 | + <br> |
| 130 | + The king is the most important piece and cannot move to an attacked square. A game finishes when a player cannot move on its turn. At this moment, if its king is directly attacked by the opponent pieces (<em>in check</em>), the game is lost, (<em>checkmate</em>). Otherwise the game is a draw (<em>stalemate</em>). |
| 131 | + <br> |
| 132 | + <br> |
| 133 | + Each player aims at taking pieces that could defend the king, negate king space on the board to <em>checkmate</em>. |
| 134 | + <h2 class="post_title" id="board_representations"> |
| 135 | + Board representations |
| 136 | + </h2> |
| 137 | + Let's first dive into the modeling of chess in engines. The board representation can take multiple forms, each having their benefits.<!-- , it can be square-centric or piece-centric. --> |
| 138 | + <br> |
| 139 | + A common implementation is through mailbox, an array with the size of the board (or bigger) that contains pieces. |
| 140 | + <pre><code class="language-rs">const board: [64]Piece = @splat(Piece.none); |
| 141 | +// or |
| 142 | +const board: [8][8]Piece = @splat(@splat(Piece.none));</code></pre> |
| 143 | + This is convenient to quickly assert which piece type is on a specific tile but has some drawbacks: |
| 144 | + <ul> |
| 145 | + <li>Memory cost</li> |
| 146 | + <li>Accession cost</li> |
| 147 | + <li>Operations are tile by tile</li> |
| 148 | + </ul> |
| 149 | + Such issues can be mitigated with bitboards. A bitboard makes the corresponding between a tile on the board and a bit of an integer. For an 8 by 8 chess board, this requires a 64 bits integer. Since only occupancy is described, a single bitboard cannot be enough for piece type nor piece color information. The board state can be described with 8 bitboards, 6 for each piece types and and 2 for each piece colors. A specific piece can be found with a bitwise and between bitboards such as: |
| 150 | + <pre><code class="language-rs">const bb_pieces: [PieceType.nb()]u64 = @splat(0); |
| 151 | +const bb_color: [Color.nb()]u64 = @splat(0); |
| 152 | +... // Fill bitboards |
| 153 | + |
| 154 | +const white_knights: u64 = bb_pieces[PieceType.knight] & bb_color[Color.white];</code></pre> |
| 155 | + Extracting the position of a piece in a bitboard can be done with bit manipulation functions by computing the least bit function. |
| 156 | + <pre><code class="language-rs">pub inline fn lsb(x: u64) u7 { |
| 157 | + return @ctz(x); |
| 158 | +} |
| 159 | + |
| 160 | +pub inline fn popLsb(x: *u64) Square { |
| 161 | + const l: u7 = lsb(x.*); |
| 162 | + x.* &= x.* - 1; |
| 163 | + return @enumFromInt(l); |
| 164 | +}</code></pre> |
| 165 | + <pre><code class="language-rs">while (white_knights != 0) |
| 166 | + const white_knight_position: Square = popLsb(&white_knights);</code></pre> |
| 167 | + <h2 class="post_title" id="move_representations"> |
| 168 | + Move representations |
| 169 | + </h2> |
| 170 | + A minimal definition of a move is the <i>from</i> tile of the moving piece and the <i>to</i> tile. This can be extended with flags and can improve the code readability and performances. For example, knowing a move is a capture without verifying that the <i>to</i> tile is occupied provide a speedup. |
| 171 | + <br> |
| 172 | + With a maximum value of 64, <i>from</i> and <i>to</i> tiles can be stored in a 6 bits integer and the 16 types of flags can be stored with 4 bits. This is convenient as an move then requires 16 bits which can be stored into an integer, data can be access with bit shifting. |
| 173 | + <br> |
| 174 | + With Zig elegant packed struct no shifting mechanics are needed: |
| 175 | + <pre><code class="language-rs">pub const Move = packed struct { |
| 176 | + flags: u4 = MoveFlags.quiet.index(), |
| 177 | + from: u6, |
| 178 | + to: u6, |
| 179 | +};</code></pre> |
| 180 | + <h2 class="post_title" id="search_and_evaluation"> |
| 181 | + Search and Evaluation |
| 182 | + </h2> |
| 183 | + A chess game is a succession of moves up to a final position. At each turn, each player evaluate every possible moves to pick the one that has the most chances to win. |
| 184 | + <br> |
| 185 | + To pick a move, the player has to visualize the future board state after doing the move and anticipate the opponent response in the list of all possible moves. This can be done multiple time and is called the <em>depth</em> of analysis. |
| 186 | + <br> |
| 187 | + <br> |
| 188 | + All these combinations of moves can be represented in a tree, the root being the starting position with the first nodes being the first possible moves. In a chess engine looking for possible moves deeper and deeper in the tree is called <em>searching</em>, choosing the best leaf is called <em>evaluating</em>. |
| 189 | + <br> |
| 190 | + In general, chess game trees have a branching factor of around 30, it is possible to compute the number of leaves for a specific depth with the formula $ leaves = branching\_factor^{depth} $. Hence at depth of 5 is 24 millions leaves while a depth of 8 is 656 billions. A standard chess game has a depth of around 80. |
| 191 | + |
| 192 | + <h3 class="post_title" id="minimax"> |
| 193 | + Minimax |
| 194 | + </h3> |
| 195 | + During the game, every player is looking for the best possible leaf in the tree. This behavior can be modeled with a <em>minimax algorithm</em>. At each node, a player will try to maximize the final score and the other one will try to minimize it. It is possible to trace a path in the tree that satisfies both players to eventually arrive at a leaf and evaluate the position. |
| 196 | + <br> |
| 197 | + Since the game tree can be very long, most of the time, a leaf will not be the final position of the game (win/draw/loss) but another, more advanced position. Chess engine evaluations have to use heuristics to determine <i>how likely</i> is the win from this new board state. This likelihood of win is also called <em>score</em> and often spans from -30 to +30. |
| 198 | + <h3 class="post_title" id="alpha-beta"> |
| 199 | + Alpha-Beta Pruning and Iterative Deepening |
| 200 | + </h3> |
| 201 | + Minimax requires all the leaves to be evaluated but their number grows exponentially with depth. There is a way to prune some branches without reduce faithfulness and this can be done with alpha-beta pruning. |
| 202 | + <br> |
| 203 | + Once a first leaf has been evaluated it gives a boundary of the attainable score for each player. Since the players want to either maximize or minimize the score value, none of them would take a path that could result in a worse score than a previously found one. |
| 204 | + <br> |
| 205 | + A path that will give a score higher than beta would never arise because the losing player would prefer to take the path that obtain a score of beta. Conversely, current player can avoid getting a lower score than alpha by taking the path of alpha. All paths outside of the alpha-beta bounds will be avoided by each player. |
| 206 | + <br> |
| 207 | + In chess, the likelihood of win is reversible, a the opponent of a player that has a 80% win has 20% chances of lose (or draw). Alpha and beta bounds can then be switched when changing to the other player point of view and the score can be negated. This newer version of minimax is called negamax. |
| 208 | + <br> |
| 209 | + <pre><code class="language-rs">fn negamax(alpha : Value, beta : Value, depth: u8) Value { |
| 210 | + |
| 211 | + // Termination, leaf evaluation |
| 212 | + if (current_depth <= 0) { |
| 213 | + return evaluation(); |
| 214 | + } |
| 215 | + |
| 216 | + var score: Value = -value_none; |
| 217 | + |
| 218 | + // Loop over all legal moves |
| 219 | + for (legal_moves) |move| { |
| 220 | + position.movePiece(move); |
| 221 | + |
| 222 | + // Transposition Table probing for early return |
| 223 | + |
| 224 | + score = negamax(-beta, -alpha, depth - 1); |
| 225 | + |
| 226 | + position.unMovePiece(move); |
| 227 | + |
| 228 | + if (score > alpha) { |
| 229 | + // Fail-high, opponent won't take this path |
| 230 | + if (score >= beta) { |
| 231 | + break; |
| 232 | + } else { |
| 233 | + // Found better alpha |
| 234 | + alpha = score; |
| 235 | + } |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + // If no move stalemate or checkmate |
| 240 | + if (score == -value_none) { |
| 241 | + if (pos.state.checkers != 0) |
| 242 | + return -value_mate + ply_number; |
| 243 | + return value_stalemate; |
| 244 | + } |
| 245 | +};</code></pre> |
| 246 | + <br> |
| 247 | + The first evaluated paths give alpha-beta bounds, also called <em>windows</em>, if the best path is selected first, the narrower the window will be, and the closer it will be to the actual score of the position. With a smaller window, most of the remaining paths will be pruned and very few evaluations will be necessary. With these characterics emerges <em>iterative deepening</em>. |
| 248 | + <br> |
| 249 | + <br> |
| 250 | + A depth of 80 is not attainable with time (and memory) constraints, it is necessary to select a specific depth and avoid getting incomplete results. |
| 251 | + <br> |
| 252 | + Iterative deepening consists in first searching for depth 1, then depth 2 and so on with each new search initialized using the previously best-scoring path. With alpha-beta pruning, searching depth 1, 2, 3 and 4 successively with the best path first takes almost the same time as searching at depth 4 directly. |
| 253 | + <br> |
| 254 | + DIAGRAM? |
| 255 | + <br> |
| 256 | + With the first path as the most important one, many optimizations are possible. The search framework can be upgraded into <em>principal variation search</em> (PVS). This method assumes that only the first path (principal) has to be searched up to the newer depth with a full alpha-beta window. Secondary paths are searched with a null window which corresponds to a bound of [alpha-1, alpha]. Such method allows to quickly remove all worse paths with aggressive pruning. |
| 257 | + <br> |
| 258 | + During PVS, it can happen that a secondary path gives a result better than alpha, it is then necessary to re-do a search with a full window to improve the position. This is costly and can be avoided by improving the move ordering. |
| 259 | + <br> |
| 260 | + PVS, Late move reductions. |
| 261 | + |
| 262 | + <h2 class="post_title" id="move_ordering"> |
| 263 | + Move Ordering |
| 264 | + </h2> |
| 265 | + With pruning, move ordering is a key factor of a chess engine. It is possible to sort move types into two categories, capturing moves and quiet moves. |
| 266 | + <br> |
| 267 | + Capturing moves are often changing board state more thant quiet moves and needs to be studied carefully by chess engines. Since chess piece types have different movesets, some are more valuable than other. Ranking captures can be done in multiple ways. |
| 268 | + <br> |
| 269 | + The most simple way is called <i>Most Valuable Victim - Least Valuable Attacker</i> (MVV-LVA). The best capture is the piece that has the less value on the board that takes the most valuable attacked piece. |
| 270 | + <br> |
| 271 | + This method only seek for a single capture but pieces are often defended, and it is more reliable to compute captures and re-captures. This is done with the Static Exchange Evaluation (SEE). |
| 272 | + <h4 class="forehead undertow charming hella"> |
| 273 | + Static Exchange Evaluation |
| 274 | + </h4> |
| 275 | + SEE computes the series of capture on a specific tile of the board. It has to take into account only legal moves. In some cases the exact value of the series of is not needed and this allows early returns by using a threshold. For example, in move ordering it is possible to use a threshold of 1 which which tells if the series of capture benefits the player. Other thresholds can be useful, in bad positions, only looking for hard winning series of exchange is needed. |
| 276 | + <br> |
| 277 | + |
| 278 | + <pre><code class="language-rs">pub fn seeGreaterEqual(move: Move, threshold: Value) bool { |
| 279 | + |
| 280 | + if (move.isEnPassant()) |
| 281 | + return threshold >= value_zero; |
| 282 | + |
| 283 | + const from: Square = move.getFrom(); |
| 284 | + const to: Square = move.getTo(); |
| 285 | + const from_piece: Piece = pos.board[from]; |
| 286 | + const to_piece: Piece = pos.board[to]; |
| 287 | + |
| 288 | + var see = material_values[to_piece.pieceToPieceType()] - threshold; |
| 289 | + |
| 290 | + // Cannot gain more than initial piece |
| 291 | + // Opponent would not give us more |
| 292 | + if (see < 0) |
| 293 | + return false; |
| 294 | + |
| 295 | + see = tables.material[from_piece.pieceToPieceType()] - see; |
| 296 | + |
| 297 | + // If we took a valuable piece from a least valuable piece |
| 298 | + // with a difference above threshold we exit |
| 299 | + // Equivalent to MVV-LVA |
| 300 | + if (see <= 0) { |
| 301 | + return true; |
| 302 | + } |
| 303 | + |
| 304 | + position.movePiece(move); |
| 305 | + |
| 306 | + var result: bool = true; |
| 307 | + |
| 308 | + // Begin series of exchange |
| 309 | + while (true) { |
| 310 | + position.updateBlockersAndPinned(); |
| 311 | + position.updateAttackersAndDefenders(); // set attackers and defenders |
| 312 | + |
| 313 | + // Cannot defend piece |
| 314 | + if (position.attackers == 0) |
| 315 | + break; |
| 316 | + |
| 317 | + result = !result; |
| 318 | + |
| 319 | + // Search for least valuable attacker type |
| 320 | + // Remove it, add new attackers and change turn |
| 321 | + for (std.enums.values(types.PieceType)) |pt| { |
| 322 | + |
| 323 | + // If capture with king but opponent still has attackers we lose |
| 324 | + if (pt == .king) |
| 325 | + return if (position.defenders > 0) !result else result; |
| 326 | + |
| 327 | + // Look for PieceType in attackers |
| 328 | + const bb: Bitboard = pos.attackers & pos.bb_pieces[pt.index()]; |
| 329 | + if (bb == 0) |
| 330 | + continue; |
| 331 | + |
| 332 | + // Update SEE score if found |
| 333 | + see = tables.material[pt.index()] - see; |
| 334 | + |
| 335 | + const new_move = computeNewMove(); |
| 336 | + position.movePiece(new_move); |
| 337 | + } |
| 338 | + |
| 339 | + // Early break because retaking was not worth it from current |
| 340 | + if (see < @intFromBool(result)) |
| 341 | + break; |
| 342 | + } |
| 343 | + |
| 344 | + return result; |
| 345 | +}</code></pre> |
| 346 | + |
| 347 | + SEE, transposition table |
| 348 | + <h2 class="post_title" id="reductions"> |
| 349 | + Reductions |
| 350 | + </h2> |
| 351 | + <h2 class="post_title" id="quiescence"> |
| 352 | + Quiescence |
| 353 | + </h2> |
| 354 | + <h2 class="post_title" id="staged_move_generation"> |
| 355 | + Staged Move Generation |
| 356 | + </h2> |
| 357 | + Update state pinned on move |
| 358 | + <h2 class="post_title" id="magic_bitboard"> |
| 359 | + Magic Bitboard |
| 360 | + </h2> |
| 361 | + <h2 class="post_title" id="transposition_table"> |
| 362 | + Transposition Table |
| 363 | + </h2> |
| 364 | + <h2 class="post_title" id="piece-square_table"> |
| 365 | + Piece-Square Table |
| 366 | + </h2> |
| 367 | + <h2 class="post_title" id="tapered_evaluation"> |
| 368 | + Tapered Evaluation |
| 369 | + </h2> |
| 370 | + </div> |
| 371 | + |
| 372 | + <footer id="footer" class="forehead +++"></footer> |
| 373 | +</body> |
| 374 | + |
| 375 | +</html> |
0 commit comments