Introduction
A Chess Engine in Fewer Than 250 Lines
What if you could build a working chess engine in JavaScript using fewer than 250 lines of code? Not a collection of disconnected examples, not a simplified demonstration that can only make a few moves, but a real chess engine with move generation, a search algorithm, and a graphical interface that lets you play against it. That is exactly what we are going to explore in this book.
Chess programming is a fascinating combination of algorithms, data structures, optimization, and problem-solving. At first glance, a chess engine might seem like a complicated piece of software that requires thousands of lines of code and an elaborate architecture. And indeed, modern competitive chess engines can be enormous projects, containing highly sophisticated search algorithms, evaluation functions, and numerous performance optimizations.
But there is another way to approach the problem. Instead of trying to build the strongest possible engine, we can focus on something equally interesting: building a functional chess engine while keeping the code remarkably small.
Our goal is not to compete with the world's strongest chess programs. It is to understand how a chess engine works, how its essential components fit together, and how careful programming can produce a useful result without drowning us in unnecessary complexity.
One HTML File, One Script, One Engine
The entire project lives in a single HTML file. Inside that file, we have one script tag containing both the chess engine and the graphical user interface. There is no separate JavaScript application, no collection of modules, no build system, and no framework that we need to install before we can start experimenting.
You can open the file, inspect the source code, and see how the application works from beginning to end. The interface and the engine are right there together, in one place.
This is a deliberate design decision. Separating code into multiple files is often beneficial for larger projects, but it is not always necessary for a small, self-contained application. When our objective is to understand an algorithm and experiment with it, keeping everything together makes the entire system easier to explore.
There is also something satisfying about being able to share a complete chess engine as a single HTML file. No installation process, no complicated project setup, and no dependency on a large software ecosystem. You have the source code, a browser, and a chessboard ready to play.
Our chess engine is written in JavaScript and contains fewer than 250 lines of code. The engine and graphical interface live together inside a single HTML file, making the project easy to read, run, modify, and experiment with.
Three Functions, Three Responsibilities
One of the most interesting aspects of this project is that its essential functionality can be organized around just three main functions: movegen(), search(), and ui().
Each function has a clear responsibility. Together, they form the application.
The first function, movegen(), generates the moves available in a chess position. A chess engine needs to understand how the pieces move, which squares they can reach, and which moves are permitted by the rules of chess. Move generation provides the foundation on which the rest of the engine operates.
The second function, ui(), draws the chessboard and handles interaction with the user. It provides the visual part of the application, allowing you to see the current position, make moves, and play against the engine.
The third function, search(), looks for the best move. Generating possible moves is only the beginning: the engine must also examine possible continuations and decide which move appears most promising. This is where the engine starts behaving like a chess-playing program rather than simply a collection of chess rules.
These three functions represent three different problems: understanding the rules, presenting the game to the player and choosing a best move. By keeping their responsibilities distinct, we can understand the architecture without introducing a large number of additional functions or layers of abstraction.
movegen() generates moves. ui() draws the board and lets the user play. search() finds the best move. These three functions form the entire application.
Efficient Code Is Optimization Target
When programmers talk about optimization, they often think exclusively about execution speed. A faster algorithm, fewer calculations, or a more efficient data structure can make a program run better.
But there is another kind of optimization that is particularly interesting for small projects: optimizing the size of the implementation itself.
In this book, code size is an important design constraint. We want to accomplish as much as possible with as little code as reasonably practical. That encourages us to question unnecessary abstractions, avoid repetitive implementations, and look for compact ways to express common operations.
However, compact code is not automatically efficient code. A shorter implementation is not necessarily faster, and removing every descriptive name or line break would make a program harder to understand rather than better designed. The challenge is to find a useful balance between brevity, performance, and readability.
JavaScript makes this experiment particularly accessible. It runs directly in modern browsers and provides everything we need to build an interactive chess application. We can focus on the engine itself rather than spending our time configuring a complicated development environment.
Who Is This Book For?
This book is for anyone curious about how chess engines work and interested in learning through a compact, practical implementation.
If you are learning JavaScript, the project gives you an opportunity to work with functions, conditional logic, loops, data representation, and algorithms in a meaningful context. Rather than studying isolated examples, you can see how these concepts cooperate inside a complete application.
If you already know how to program, the project offers a different kind of challenge. You can explore how to reduce code duplication, express algorithms concisely, and make deliberate trade-offs between code size, clarity, and performance.
And if you simply enjoy chess, you can explore the machinery behind a program that chooses its own moves.
You do not need to be an expert in artificial intelligence, advanced mathematics, or software architecture to get started. What matters most is curiosity and a willingness to examine how the pieces fit together.
What We Are Going to Build
By the end of this book, you will understand the structure of a small JavaScript chess engine and how its core components cooperate to create a playable application.
You will explore move generation, the search for promising moves, and the graphical interface that allows a human to play against the engine. Along the way, you will see how these responsibilities can be organized into a compact implementation without relying on a large collection of duplicated code.
The final result is deliberately modest in size but ambitious in what it demonstrates: a chess engine written in JavaScript, contained in a single HTML file, with fewer than 250 lines of code.
There is something satisfying about such a small program doing something that initially appears so complicated. It reminds us that understanding a problem is often more valuable than throwing more code at it.
That is the central idea of this book: understand the algorithm, eliminate unnecessary complexity, and build a chess engine with remarkably little code.
Representing the Chessboard
Before our engine can generate moves or search for the best one, it needs a way to represent a chess position. We need to know which pieces occupy which squares, whose turn it is, whether castling is possible, and whether an en passant capture is available. In a larger chess engine, this information might be spread across several structures and modules. In our implementation, we keep the essential state in a small collection of variables and arrays.
// State
var board = new Array(128), side = 8, ep = -1, castle = 15;
var offsets = [ [0], [-16, -1, 16, 1, -17, -15, 17, 15],
[15, 16, 17], [-33, -31, 33, 31, -18, -14, 18, 14], [-17, -15, 17, 15],
[-16, -1, 16, 1], [-16, -1, 16, 1, -17, -15, 17, 15]
]; let material = [0, 1000, 100, 300, 300, 400, 600, 900];
let pst = Array(128).fill(0); var pvTable, pvLength;
var prom = 6, nodes = 0, stop = 0, engineSide = 16;
let userSide = window.location.href.split("play=")[1];
userSide == "white" ? (engineSide = 16) : (engineSide = 8); ui();
if (engineSide == 8) ui(), document.getElementById(0).classList.add("highlight"),
src = 0, ui();
Let's start with the first line of our engine:
var board = new Array(128), side = 8, ep = -1, castle = 15;
This single line initializes four important pieces of information. The board array represents the chessboard, side identifies the side to move, ep stores the en passant target square, and castle tracks the remaining castling rights.
Notice how several variables are declared in one statement. JavaScript allows us to do this, and it helps keep the initialization compact. We will use this style throughout the engine whenever it improves code density without making the implementation unnecessarily difficult to follow.
The 0x88 Board Representation
The most interesting part of the initialization is the size of our board array: 128 elements.
A conventional chessboard contains only 64 squares, so why do we allocate twice that number? The answer is a representation technique known as the 0x88 board.
Instead of arranging the 64 playable squares consecutively in a one-dimensional array, we represent the board as an eight-by-sixteen grid. Only the first eight squares of each row belong to the actual chessboard. The remaining eight positions are unused and help us detect when a move goes off the board.
Every square can be represented by an integer from 0 to 127. The low four bits identify the file, while the remaining bits identify the rank. In practical terms, we can calculate a square's file using square & 7 and its rank using square >> 4.
The real advantage appears when we generate moves. If a square is outside the board, the expression square & 0x88 will be nonzero. We can therefore detect an invalid square with a simple bitwise operation rather than checking its rank and file separately.
For example, consider moving a rook horizontally toward the edge of the board. With the 0x88 representation, we can keep adding the rook's movement offset and check whether the resulting square is valid. As soon as the move reaches an off-board position, the test identifies it immediately.
This technique makes move generation compact and straightforward. We spend a few extra array positions to simplify one of the most frequent operations in the engine: determining whether a square exists on the board.
The 0x88 representation uses a 16-by-8 array, but only 64 positions correspond to playable squares. The extra positions make off-board detection particularly simple: square & 0x88 is nonzero when the square lies outside the chessboard.
Piece Encoding
Before we can generate moves, we need a compact way to represent the pieces on our board. We encode each piece as a small integer whose individual bits carry different pieces of information.
The two most important bits identify the color. If bit 8 is set, the piece is White. If bit 16 is set, the piece is Black. The remaining bits encode the piece type, allowing the engine to determine whether a piece is a king, pawn, knight, bishop, rook, or queen.
Our piece types use the following values: 0 represents an empty square, 1 a king, 2 a pawn, 3 a knight, 4 a bishop, 5 a rook, and 6 a queen. Combining the piece type with its color produces a single integer that tells us everything we need to know about the piece.
For example, a white pawn is represented by setting the White bit and combining it with the pawn type. A black queen uses the Black bit combined with the queen type. This makes it possible to identify both the color and the type of a piece using simple bitwise operations rather than maintaining separate variables or objects for each piece.
There is another useful detail in our encoding. Bit 4 identifies sliding pieces. Bishops, rooks, and queens have this bit set, while kings, pawns, and knights do not. This distinction becomes particularly handy during move generation because sliding pieces can continue moving in the same direction across multiple squares, whereas leaping pieces move directly to their destinations.
By encoding this property directly into the piece representation, we can test whether a piece is a slider with a simple bitwise operation. We do not need to maintain another lookup table or write a separate check for every sliding piece type.
This is a small example of the design philosophy behind our engine: store useful information directly in the representation so that the code using it can remain compact and efficient.
Piece Movement With Offsets
Next, we have the offsets array:
var offsets = [ ... ];
This array stores the directions in which different pieces can move. Rather than writing separate coordinate calculations for every direction, we represent movement as integer offsets that can be added directly to a square index.
On our 0x88 board, moving one rank upward or downward changes the square index by 16. Moving one file left or right changes it by 1. Diagonal movement combines these changes, producing offsets such as -17, -15, 17, and 15.
For example, a rook uses the offsets -16, -1, 16, and 1. A bishop uses the four diagonal offsets. A queen can move in all eight directions, so it combines the rook's and bishop's directions.
Knight movement works in the same general way, although its offsets are different because a knight moves two squares in one direction and one square perpendicular to it. Those jumps are represented by offsets such as -33, -31, -18, and -14, together with their corresponding positive counterparts.
The array contains entries for the different piece types, with the exact index-to-piece mapping determined by the engine's internal encoding. The important idea is that each piece type can refer to its own collection of movement offsets.
There is a subtle but important detail here: offsets alone do not describe every rule of chess. Sliding pieces can continue along a direction until they encounter another piece or leave the board, while knights jump directly to their destinations. Pawns also have special rules for forward movement, captures, promotion, and en passant. The move-generation function uses the relevant offsets together with these rules to produce the moves appropriate for each piece.
This is a good example of how a carefully chosen representation can simplify an algorithm. Instead of repeatedly converting between rank-and-file coordinates, the engine can work directly with integer square indices.
Material Values
Our next variable is the material array:
let material = [0, 1000, 100, 300, 300, 400, 600, 900];
This array stores the material values associated with the engine's internal piece codes. When the engine evaluates a position, it needs some way to estimate the relative value of the pieces belonging to each side.
For example, a pawn is assigned a value of 100, a knight and bishop each receive 300, a rook receives 400, and a queen receives 900. The king has a value of 1000 in this particular table, although a king's value in a chess engine is not directly comparable to the ordinary material value of a queen or rook. The king cannot be captured in a legal game, and its safety must be handled according to the engine's search and evaluation logic.
Why use numbers such as 100, 300, and 900 instead of fractions such as 1, 3, and 9? Integer values are convenient for compact evaluation code. They also leave room for additional positional terms to be expressed using the same scale.
The values are not universal truths about chess. A knight and a bishop do not always have exactly the same practical strength, and the value of a piece depends on the position. These numbers are simply a useful starting point for estimating material balance.
By storing the values in an array, the engine can retrieve a piece's value through its encoded type rather than using a long chain of conditional statements. This is both compact and convenient.
Piece-Square Tables
Immediately after the material values, we initialize another array:
let pst = Array(128).fill(0);
The name pst stands for piece-square table. A piece-square table can assign positional bonuses or penalties depending on where a piece is located. A knight near the center of the board, for example, is often more useful than a knight trapped on the edge.
In our initialization, every entry is set to zero. This gives the engine a ready-made table that can be populated or updated with positional values later. At this stage, the initialization itself does not give any square a positional advantage; every entry begins with the same value.
Notice that the array has 128 elements, just like our board. This keeps square indexing consistent with the 0x88 representation. We can use a square index directly without converting it into a separate 64-square coordinate system.
This is another example of a small implementation choice that helps the rest of the engine stay compact. When different parts of the program share the same representation, we avoid unnecessary conversions and additional bookkeeping.
Search State and Move Promotion
We then declare a few more variables:
var pvTable, pvLength;
The names refer to the principal variation table and its associated length information. In chess search, the principal variation is the sequence of moves the engine currently considers its best continuation. Information about this line can help the engine organize its search and examine promising moves earlier.
Move ordering is important because a chess engine usually has more possible variations to examine than it can afford to explore exhaustively. If promising moves are considered first, the search may be able to eliminate less useful alternatives sooner. The principal variation can therefore provide valuable guidance during the search.
The exact use of these variables depends on the implementation of search(), but their purpose is to support the engine's search process without requiring a separate, elaborate search subsystem.
Next comes:
var prom = 6, nodes = 0, stop = 0, engineSide = 16;
The variable prom specifies the piece used for pawn promotion. In this engine, the default is the queen, which is the usual choice in ordinary play. A pawn that reaches the final rank can promote to a more powerful piece, and a queen is generally the most useful option.
The variable nodes counts the positions or search nodes examined by the engine. Tracking this number provides a simple way to limit the amount of work performed during a search. The stop variable acts as a stop flag, allowing the search to terminate when the relevant stopping condition is reached. In our implementation, this can be used to stop searching after a node-count threshold has been met.
Finally, engineSide identifies the side played by the computer. Its initial value is set to 16, and the initialization code below determines which side the engine should play.
Choosing White or Black
The next few lines connect the engine to the page URL:
let userSide = window.location.href.split("play=")[1];
This reads the current page URL and extracts the portion that follows play=. The resulting value is used to determine which side the human player wants to play.
The following conditional expression makes the choice:
userSide == "white" ? (engineSide = 16) : (engineSide = 8);
If the URL specifies play=white, the engine is assigned the black side. Otherwise, it is assigned the white side. The values 8 and 16 are internal side identifiers used by the engine; they are not the literal names of the colors.
This is a compact way to let the same application support both playing as White and playing as Black. There is no need to maintain two separate versions of the interface or engine.
There is one practical caveat: this expression assumes that the URL contains the expected parameter and value. A production application might parse URL parameters more robustly with URLSearchParams, but the compact approach illustrates the basic idea clearly.
Drawing the Board and Initializing the Game
After choosing the engine's side, we call:
ui();
This invokes the user-interface function, which draws the board from the appropriate perspective. The same underlying chess position can be presented from White's side or Black's side, depending on which color the human is playing.
The final conditional handles the initial setup when the engine plays White:
if (engineSide == 8) ui(), document.getElementById(0).classList.add("highlight"), src = 0, ui();
When this condition is true, the code calls ui(), adds the CSS class highlight to the element with the ID 0, sets src to zero, and calls ui() again.
The exact significance of the highlighted square and the src variable depends on how the interface handles selection and the initial position. The important point is that this block performs additional interface setup for the case where the engine plays White. Rather than creating a separate initialization system, it reuses the same UI function.
This compact style is typical of the project: the engine and interface share a small set of state variables and functions, and the startup logic configures them according to the selected playing side.
Building the User Interface
Now we can move on to the ui() function, which connects our chess engine to the user. It draws the board, displays the pieces, highlights legal moves, handles the player's selections, and starts the engine's search when it is time for the computer to move.
We deliberately start with the user interface rather than waiting until the move generator and search are complete. This gives us a convenient way to debug the engine as we build it. For example, we can click a piece and inspect the highlighted destination squares to see whether the engine generates the moves we expect. A graphical board makes mistakes much easier to spot than a list of square numbers printed in a console. We can test the move generator visually from the beginning, even before the engine knows how to choose its own moves.
// GUI
function ui(dst) {
if (document.getElementById("board") == null) {
let size = (Math.floor(Math.min(window.innerWidth, window.innerHeight) / 8)) - 2;
let ui = ""; for (var row = 0; row < 8; row++) { ui += "";
for (var col = 0; col < 16; col++) {
if (engineSide == 8) { file = 16 - 1 - col; rank = 8 - 1 - row; }
else { file = col; rank = row; } let i = rank * 16 + file;
if (i & 0x88) continue; ui += ``;
}
} document.write(`
${ui}
`); board.fill(15);
let i = 8; while (i--) {
board[i] = (board[i+112] = [13, 11, 12, 14, 9, 12, 11, 13][i])+8;
board[i+16] = 18; board[i+32] = 0; board[i+48] = 0; board[i+64] = 0;
board[i+80] = 0; board[i+96] = 10;
} board[8] = 7; board[12] = 3; board[15] = 11;
board[120] = 13; board[124] = 12; board[127] = 14;
for (let j = 0; j < 8; j++) for (let i = 0; i < 8; i++)
pst[16 * j + i] = 28.25 - (i - 4) ** 2 - (j - 3.5) ** 2;
} let lock = 0; for (let sq in board) {
if (sq & 0x88) continue;
if (document.getElementById(sq).classList[0] == "highlight") lock = 1;
s=document.getElementById(sq);
s.classList.remove('highlight');
s.innerHTML = " \u265a\u265f\u265e\u265d\u265c\u265b--\u2654\u2659\u2658\u2657\u2656\u2655"[board[sq]&15];
} for (let move of movegen(-1, -1, -1)) {
let oldBoard = JSON.stringify(board), oldSide = side, oldEp = ep, oldCastle = castle;
movegen(move[0], move[1], move[2]); let isLegal = 1;
if (!movegen(-1, -1, -1).length) isLegal = 0; board = JSON.parse(oldBoard);
side = oldSide, ep = oldEp, castle = oldCastle;
if (!isLegal) continue; if (dst == move[0]) {
let s = -1; while (++s < 2) document.getElementById(move[s]).classList.add('highlight');
src = dst;
}
} if (lock) {
let oldBoard = JSON.stringify(board), oldSide = side, oldEp = ep, oldCastle = castle;
movegen(src, dst, prom); if (!movegen(-1, -1, -1).length)
board = JSON.parse(oldBoard), side = oldSide, ep = oldEp, castle = oldCastle;
else { if (side == engineSide) {
setTimeout(function() {
let lastPV = []; stop = 0; let d = 0; while (++d <= 15) {
search(d, 0, -50000, 50000, 0, 0);
if (stop) break;
if (!pvLength[0]) break;
lastPV = JSON.parse(JSON.stringify(pvTable[0]));
} src = -1; dst = -1;
if (!lastPV.length) return;
movegen(lastPV[0][0], lastPV[0][1], lastPV[0][2]); ui();
document.getElementById(lastPV[0][1]).classList.add('highlight');
}, 1);
}
}
}
}
Our function accepts one optional argument, dst, representing a destination square selected by the user. When we call ui() without an argument, it updates the board and processes the current game state. When we call it with a square index, it can also highlight the legal destinations associated with that square.
Creating the Board Only Once
We begin by checking whether the HTML board already exists:
if (document.getElementById("board") == null) {
The first time ui() runs, there is no element with the ID board, so we enter this block and create the board. On subsequent calls, the board already exists, and we skip its creation. This distinction is important because we will call ui() repeatedly throughout the game. We want to update the existing board, not rebuild the entire page after every move.
Next, we calculate the size of each square:
let size = (Math.floor(Math.min(window.innerWidth, window.innerHeight) / 8)) - 2;
window.innerWidth and window.innerHeight give us the dimensions of the browser's viewport. We take the smaller of the two so that the board can fit within either the available width or the available height. Dividing by eight gives us an approximate square size, since the chessboard has eight squares along each side. Math.floor() rounds the result down to an integer, and subtracting two pixels leaves a little room for spacing and other layout details.
This is a simple responsive sizing strategy: the board adapts to the browser window without requiring a separate stylesheet for different screen sizes.
Generating the HTML Squares
We prepare a string that will eventually contain the board's HTML:
let ui = "";
We then use two nested loops to generate the rows and squares:
for (var row = 0; row < 8; row++) { ui += "<tr>";
The outer loop runs eight times, once for each row. At the beginning of every iteration, we append an opening <tr> tag, which starts a new table row.
Inside it, the inner loop runs sixteen times:
for (var col = 0; col < 16; col++) {
At first glance, sixteen columns might seem like a mistake. Our chessboard has only eight files, after all. The reason is our 0x88 board representation. Each rank occupies sixteen array positions, even though only eight of those positions correspond to real squares. The other eight are padding positions that help us detect the edge of the board efficiently.
We use the same layout when constructing the HTML, then skip the padding positions. This keeps the board-generation code aligned with the engine's internal square numbering.
Before deciding which square to create, we determine how the board should be oriented:
if (engineSide == 8) { file = 16 - 1 - col; rank = 8 - 1 - row; }
else { file = col; rank = row; }
The variables file and rank describe the square's horizontal and vertical coordinates. If the engine plays Black, we reverse both coordinates so that the board is displayed from Black's perspective. Otherwise, we use the normal order.
Notice that the condition uses engineSide, not the side whose turn it currently is. The board orientation is determined by the player's chosen color, so it should not flip back and forth as the turns change.
We then convert the coordinates into our internal square index:
let i = rank * 16 + file;
Multiplying the rank by sixteen moves us to the beginning of that rank in the 0x88 array. Adding the file selects the square within it. This is the same addressing scheme used by the engine itself.
Now comes the key part of the loop:
if (i & 0x88) continue;
This is the familiar 0x88 boundary test. If the square index has either of the relevant high bits set, it belongs to the padding area rather than the playable board. The continue statement skips the rest of the current iteration, so no HTML square is created for that position.
As a result, the loop considers sixteen positions per row but creates only eight visible squares. After eight rows, we have generated the sixty-four actual chessboard squares.
Giving Each Square an Identity
For every valid square, we append a <th> element to the HTML string:
ui += `<th id="${i}" ... >`;
The square's internal index becomes its HTML id. For example, the element representing square zero has id="0". This is a useful shortcut: whenever the user clicks a square, we can recover the engine's square index directly from the element rather than maintaining a separate mapping between the interface and the board array.
We also assign a background color:
background-color: #${(((i%16+i/16|0)%2)?"777777":"dddddd")};
This expression calculates which of the two alternating colors to use. The expression i % 16 extracts the file, while i / 16 | 0 truncates the division result to an integer, effectively giving us the rank for a valid square index. Adding the two coordinates and taking the remainder after division by two tells us whether the square belongs to one color or the other.
The bitwise OR with zero is a compact JavaScript trick for converting a number to a signed 32-bit integer, which truncates the fractional part in this case. Since our valid square indices are nonnegative, it has the effect we want here.
The two color values are hexadecimal RGB colors: 777777 is a medium gray, and dddddd is a light gray. Together, they create the familiar checkerboard pattern.
We set the dimensions of each square using the value we calculated earlier:
width: ${size}px;
height: ${size}px;
The template literal allows us to insert JavaScript values directly into the HTML string. This is why we use backticks rather than ordinary quotation marks.
Connecting Clicks to the Engine
Each square also receives an inline click handler:
onclick="let i = 2; while(i--) ui(parseInt(this.id));"
When a square is clicked, this refers to the HTML element that received the click. Its id contains the square index, and parseInt(this.id) converts that string into a number.
The handler calls ui() twice with the same square index. The loop let i = 2; while(i--) simply repeats the call twice. This compact interaction works together with the selection logic later in the function, allowing the first call to update the selection state and the next call to refresh the interface based on that state.
Writing the Board into the Page
After the loops finish, the variable ui contains the generated square elements. We use document.write() to insert the board and its supporting styles into the page:
document.write(`...<table id="board"...>${ui}</table>`);
The generated markup includes a viewport setting that disables user scaling:
<meta name="viewport" content="user-scalable=no">
This is intended to keep the board from being zoomed through the usual touch gestures on mobile browsers. The exact effect depends on browser behavior and accessibility settings, so it should be considered a convenience rather than a guarantee.
We then define a CSS class:
.highlight { box-shadow: inset 0px 0px 3px 3px #ffbb00; }
The highlight class draws a yellow border-like shadow inside a square. We use it to indicate a selected square and its legal destinations.
The table itself receives several inline styles. Its font size is set relative to the square size, the cursor is kept as the default pointer, and border-spacing: 0px removes the gaps between table cells. The declarations display: grid and place-items: center attempt to center the table's contents using CSS Grid. The remaining declarations prevent text selection and suppress the usual mouse-selection behavior, which is useful because we do not want dragging across squares to select the Unicode chess symbols as ordinary text.
Once the HTML has been written, we initialize the board array:
board.fill(15);
Every entry is initially assigned the value 15. Right side of the board is used to track castling rights after rooks or kings moved.
Setting Up the Starting Position
We now initialize the pieces and empty squares:
let i = 8; while (i--) { ... }
This loop runs eight times, with i taking the values from seven down to zero. It initializes one file at a time across the two back ranks and the six ranks between them.
The first statement is the densest part:
board[i] = (board[i+112] = [13, 11, 12, 14, 9, 12, 11, 13][i])+8;
The array literal [13, 11, 12, 14, 9, 12, 11, 13] contains the encoded pieces for the back rank. Reading from file zero to file seven, it represents rook, knight, bishop, queen, king, bishop, knight, and rook in this engine's piece encoding.
The index i + 112 points to the corresponding square on the eighth rank. Each rank occupies sixteen array positions, so the last rank begins at index 112. Assigning the array value to board[i+112] places the encoded piece on that rank.
The outer assignment then stores that same value plus eight in board[i]. Adding eight sets the White color bit, producing the corresponding White piece on the first rank. In this way, the statement initializes both back ranks using the same piece-type values for each color.
The next statements initialize the pawns and the empty ranks:
board[i+16] = 18;
board[i+32] = 0;
board[i+48] = 0;
board[i+64] = 0;
board[i+80] = 0;
board[i+96] = 10;
Each index advances by sixteen, so it moves to the same file on the next rank. The value 18 represents a Black pawn: the pawn type is combined with the Black color bit. The value 10 represents a White pawn. The four ranks between the pawn ranks are initialized to zero, our representation of an empty square.
After the loop, we adjust a few entries explicitly:
board[8] = 7; board[12] = 3; board[15] = 11;
board[120] = 13; board[124] = 12; board[127] = 14;
The board is stored in 0x88 format, with each rank occupying 16 array entries: the first eight represent playable squares, and the next eight form the right-hand padding. This padding normally contains 15, a mask that preserves all castling rights. The six exceptions—7, 3, 11, 13, 12, and 14—are aligned with the kings' and rooks' starting squares. When a move is made, castle &= board[src + 8]; castle &= board[dst + 8]; reads the mask eight entries to the right of both the source and destination squares. Each mask clears the appropriate castling-right bit: moving a king clears both rights for its color, moving a rook from its original corner clears that side's right, and capturing a rook on its original corner clears that right as well. All other squares preserve the rights because their masks are 15. This clever use of the unused padding lets the engine update castling rights with two bitwise AND operations, without separate conditions for kings, rooks, or captures.
Initializing the Piece-Square Table
Before leaving the initialization block, we populate the piece-square table:
for (let j = 0; j < 8; j++) for (let i = 0; i < 8; i++)
pst[16 * j + i] = 28.25 - (i - 4) ** 2 - (j - 3.5) ** 2;
The two loops visit all sixty-four playable squares. The expression 16 * j + i converts each rank and file into the corresponding 0x88 square index, just as we did while generating the HTML.
The assigned value is largest near the center and decreases as we move away from it. The terms (i - 4) ** 2 and (j - 3.5) ** 2 measure squared distances from the chosen horizontal and vertical reference points. Subtracting them from 28.25 produces a simple positional score that favors central squares.
This is a compact way to encourage pieces moving towards the center in quiet positions.
Refreshing the Existing Board
After the initialization block, we reach the part of ui() that runs on every call:
let lock = 0; for (let sq in board) { ... }
The variable lock records whether a square was highlighted before we refresh the display. The loop visits the enumerable indices of the board array. Because the array uses the 0x88 representation, we skip the invalid positions again:
if (sq & 0x88) continue;
We check whether the current square has the highlight class:
if (document.getElementById(sq).classList[0] == "highlight") lock = 1;
If the first class on that square is highlight, we set lock to one. This preserves the information that a selection was active before the interface is redrawn. The code checks classList[0] specifically, so it relies on the highlight class being the first class in the list.
We then retrieve the square element and remove its highlight:
s=document.getElementById(sq);
s.classList.remove('highlight');
Since we are about to recalculate which squares should be highlighted, we first clear the old highlights. Otherwise, a square that was legal in the previous position might remain highlighted after the board changes.
Next, we update the displayed piece:
s.innerHTML = " \u265a\u265f\u265e\u265d\u265c\u265b--\u2654\u2659\u2658\u2657\u2656\u2655"[board[sq]&15];
The string contains Unicode chess symbols in an order that matches the piece codes used by our engine. The expression board[sq] & 15 extracts the lower four bits of the encoded piece, allowing us to select the corresponding character regardless of the color bit. The string includes both Black and White pieces, along with placeholders for empty or special values.
This is another example of using the encoding to keep the interface code short. We do not need a long chain of conditions checking whether a square contains a king, queen, rook, or pawn. We use the piece code as an index into a string and let JavaScript return the appropriate symbol.
Finding and Highlighting Legal Moves
Once the board's appearance has been refreshed, we examine the moves available in the current position:
for (let move of movegen(-1, -1, -1)) {
Here, we call movegen() with three negative arguments to request the list of moves rather than execute a particular move. We then examine each generated move individually.
Before testing a move, we save the current position and its associated state:
let oldBoard = JSON.stringify(board), oldSide = side, oldEp = ep, oldCastle = castle;
The board is copied by converting it to a JSON string. The side to move, en-passant square, and castling rights are saved separately. These variables matter because making a move can change more than the piece locations: it can switch the side to move, create or remove an en-passant target, and alter castling rights.
We then try the move:
movegen(move[0], move[1], move[2]); let isLegal = 1;
The three elements of move represent the move's source square, destination square, and promotion choice. Calling movegen() with these arguments executes the move according to the engine's move-making logic.
Next, we test whether the resulting position has any legal moves:
if (!movegen(-1, -1, -1).length) isLegal = 0;
The intent is to reject a move if the resulting position has no legal replies.
After the test, we restore the board and the saved state:
board = JSON.parse(oldBoard);
side = oldSide, ep = oldEp, castle = oldCastle;
Parsing the saved JSON string recreates the board array, restoring the previous piece arrangement. The other three variables are restored to their original values. This lets us test each candidate without permanently changing the position that the user is viewing.
If the move failed the test, we skip it:
if (!isLegal) continue;
Otherwise, we check whether the selected destination matches the move's source square:
if (dst == move[0]) {
When it does, we highlight both the source and destination squares of the move:
let s = -1; while (++s < 2) document.getElementById(move[s]).classList.add('highlight');
The loop increments s from zero to one, so move[0] and move[1] are highlighted. In this context, the selected square is highlighted along with each legal destination, making it easy to see which piece is selected and where it can move.
Finally, we set src = dst. This records the selected square in the engine's shared state, allowing the next interaction to know which piece the user intends to move.
Executing the Selected Move
After examining the available moves, we check whether a square was highlighted before the redraw:
if (lock) {
If lock is nonzero, the function treats the interaction as a move-selection attempt. It saves the current position again, using the same technique as before:
let oldBoard = JSON.stringify(board), oldSide = side, oldEp = ep, oldCastle = castle;
Then it attempts to execute the move from src to dst:
movegen(src, dst, prom);
The variable prom specifies the promotion piece. In our engine, it defaults to the queen, so a pawn reaching the last rank will be promoted to a queen unless this value is changed.
Once again, we inspect the resulting position:
if (!movegen(-1, -1, -1).length)
If no legal moves are found, the function restores the saved board and state. Otherwise, it leaves the move applied and proceeds to the computer's turn if necessary.
Starting the Computer's Search
If the move has been accepted, we check whether it is now the engine's turn:
if (side == engineSide) {
The variable side identifies the side to move, while engineSide identifies the computer's color. When the two values match, we schedule the engine's search:
setTimeout(function() { ... }, 1);
The short delay allows the browser to return control to its event loop before the search begins. This can give the interface an opportunity to process pending work, although it does not make the search itself run in the background. Because the search is synchronous JavaScript, it can still block the interface while it is calculating.
At the beginning of the callback, we initialize the variables used by iterative deepening:
let lastPV = []; stop = 0; let d = 0;
lastPV stores the best principal variation found so far. The variable stop is reset so that the next search can run, and d records the current search depth.
We then search progressively deeper positions:
while (++d <= 15) {
This loop begins at depth one and continues up to depth fifteen, unless one of the exit conditions is reached. Searching one depth at a time is called iterative deepening. It allows the engine to keep the result of the last completed search while attempting a more expensive one.
Each iteration calls the search function:
search(d, 0, -50000, 50000, 0, 0);
The first argument is the target depth. The remaining arguments supply the search's initial ply and alpha-beta bounds, along with the other search parameters used by this engine. The wide negative and positive bounds allow the search to begin with a broad score window.
After the search returns, we check whether it should stop:
if (stop) break;
If the engine has reached its stopping condition, we leave the loop. We also stop if the principal variation table has no move recorded at its root:
if (!pvLength[0]) break;
The root entry, indexed by zero, describes the best line found from the current position. If its length is zero, there is no principal variation to save.
Otherwise, we copy the current principal variation:
lastPV = JSON.parse(JSON.stringify(pvTable[0]));
This creates a deep copy of the root line. The copy is important because later search iterations may overwrite the contents of pvTable[0]. By preserving the most recently completed result, we can still make a move if a deeper iteration is interrupted.
Once iterative deepening ends, we clear the selection state:
src = -1; dst = -1;
If no move was found, the callback returns without attempting to execute one:
if (!lastPV.length) return;
Otherwise, we play the first move in the saved principal variation:
movegen(lastPV[0][0], lastPV[0][1], lastPV[0][2]);
The first entry contains the source square, destination square, and promotion choice of the engine's selected move. Calling movegen() with these values applies it to the board.
We then refresh the interface:
ui();
Finally, we highlight the destination square of the engine's move:
document.getElementById(lastPV[0][1]).classList.add('highlight');
This provides a simple visual indication of the computer's last move. The board now displays the updated position, and the user can begin selecting a piece for the next turn.
A Small Interface With Several Responsibilities
Our ui() function brings together several tasks that are often separated in larger chess programs: generating the board markup, initializing the starting position, drawing pieces, showing legal destinations, applying user moves, and invoking the search. Keeping these responsibilities in one function helps us stay within our compact-code goal, although it also means that careful testing is essential because interface logic and chess logic are closely connected.
Most importantly, this interface gives us a practical way to develop the engine incrementally. We can inspect the board after each move, click pieces to see the generated destinations, and catch errors before adding more complicated search behavior. By making the engine visible early, we turn the board into a debugging tool as well as a way to play chess.
The Move Generator
The move generator is responsible for producing a list of pseudolegal moves: moves that follow the pieces' movement rules but have not necessarily been checked to ensure that the moving side's king remains safe. It also supports executing a selected move, handling special moves, and detecting attacks on the king. Rather than implementing separate functions for each task, we reuse the same compact routine throughout our engine.
Let's walk through the function line by line.
// Rules
function movegen(uSrc, uDst, uProm) {
var moves = [];
let src = -1; while (++src < 120) {
let pce = board[src];
if (!(pce & side) || (src & 0x88)) continue;
let pceType = pce & 7;
for (let dir of offsets[pceType]) {
if (pce == 10) dir *= -1;
let dst = src + dir;
while (!(dst & 0x88)) {
let capSq = dst;
if (pceType == 2 && dst == ep) capSq ^= 16;
let capPce = board[capSq];
if (capPce & side) break;
if (ep > 0 && board[ep] && ((dst-ep)<2) && ((ep-dst)<2)) return[];
if (pceType == 2) {
if (!((dst-src) & 7) && capPce) break;
if (((dst-src) & 7) && !capPce && capSq != ep) break;
}
if ((capPce & 7) == 1) return [];
board[capSq] = 0; board[src] = 0; board[dst] = pce;
if (pceType == 1 && ((src == 116) || (src == 4))) {
switch(dst) {
case 118: board[117] = 13; board[119] = 0; break;
case 114: board[115] = 13; board[112] = 0; break;
case 6: board[5] = 21; board[7] = 0; break;
case 2: board[3] = 21; board[0] = 0; break;
}
}
let oldCastle = castle;
castle &= board[src + 8]; castle &= board[dst + 8];
if (pceType == 2) {
if ((dst >> 4) == (side == 8 ? 0 : 7)) {
board[dst] = ((uProm > 0) ? uProm : 6) | side;
for (let p = 6; p > 2; p--)
moves.push([src, dst, p, capPce]);
} else moves.push([src, dst, 0, capPce]);
} else moves.push([src, dst, 0, capPce]);
if (src == uSrc && dst == uDst) {
ep = -1; if (pceType == 2) {
if (((src >> 4) == ((side == 8) ? 6 : 1)) &&
((dst >> 4) == ((side == 8) ? 4 : 3))) ep = dst ^ 16;
} else if (pceType == 1) {
if (src == 116) {
if (dst == 118) ep = 117;
if (dst == 114) ep = 115;
} else if (src == 4) {
if (dst == 6) ep = 5;
if (dst == 2) ep = 3;
}
}
side = 24 - side; return 'move';
}
board[dst] = 0; board[src] = pce; board[capSq] = capPce;
if (pceType == 1 && ((src == 116) || (src == 4))) {
switch(dst) {
case 118: board[119] = 13; board[117] = 0; break;
case 114: board[112] = 13; board[115] = 0; break;
case 6: board[7] = 21; board[5] = 0; break;
case 2: board[0] = 21; board[3] = 0; break;
}
}
castle = oldCastle;
if (pceType == 2) {
if ((src >> 4) != ((side == 8) ? 6 : 1)) break;
if ((dst >> 4) != ((side == 8) ? 5 : 2)) break;
} else if (pceType == 1) {
if ((dir != 1) && (dir != -1)) break;
if (dir == 1) {
if (board[src + 2]) break;
if (side == 8 && !(castle & 1)) break;
if (side == 16 && !(castle & 4)) break;
}
if (dir == -1) {
if (board[src - 2]) break;
if (board[src - 3]) break;
if (side == 8 && !(castle & 2)) break;
if (side == 16 && !(castle & 8)) break;
}
if (src != ((side == 8) ? 116 : 4)) break;
if (Math.abs(src-dst) > 1) break;
}
if (capPce & (24 - side)) break;
if (!(pceType & 4) && pceType != 2 && pceType != 1) break;
dst += dir;
}
}
}
return moves;
}
1. Setting up the move list and scanning the board
var moves = []; creates the array that will hold generated moves. Each entry has four elements: source square, destination square, promotion choice, and captured piece. let src = -1; while (++src < 120) scans board indices from zero through 119. Because the board uses 0x88 representation, the condition src & 0x88 identifies the eight padding entries in each rank, which are skipped by continue. The other part of the condition, !(pce & side), skips empty squares and pieces belonging to the opponent. pceType = pce & 7 extracts the piece type from the encoded piece value, and for (let dir of offsets[pceType]) iterates over the movement directions defined for that type.
2. Exploring each piece's destinations
if (pce == 10) dir *= -1; reverses the movement direction for white pawns. The generator then calculates dst = src + dir and continues while dst remains on the board. capSq normally equals the destination square, but for an en passant capture, capSq ^= 16 shifts it one rank to locate the pawn that is actually captured. The next line reads that piece into capPce. If the destination contains a piece of the moving side, if (capPce & side) break; ends exploration in that direction: a piece cannot capture its own side, and a sliding piece cannot move through it.
The check beginning with if (ep > 0 ...) is a special-purpose test that uses the global en passant square during castling checks. If the square being checked is occupied under the condition shown, the function returns an empty list. This compact technique is used by the engine's castling logic to reject castling when the king is in check.
The next two conditions handle pawn movement. If a pawn moves straight ahead, ((dst-src) & 7) is zero, so an occupied destination blocks the move. If the pawn moves diagonally, the destination must contain a capturable piece or be the en passant target. The line if ((capPce & 7) == 1) return []; detects a king on the capture square and returns an empty list. This is a central trick in our engine: rather than writing a separate isSqAttacked() function, we can generate the opponent's pseudolegal moves and detect whether one of them would capture the king. That makes king-capture move generation useful for checking whether a position is legal.
3. Making a temporary move
board[capSq] = 0; board[src] = 0; board[dst] = pce; temporarily removes the captured piece, clears the source square, and places the moving piece on the destination. This lets the generator inspect the position after the candidate move, including special moves. If the moving piece is a king on its original square, the following switch checks whether the destination represents castling. For each castling destination, it temporarily moves the appropriate rook to the square next to the king and clears the rook's original square. The four cases cover both directions for both colors.
let oldCastle = castle; saves the current castling rights before they are changed. The two bitwise AND operations, castle &= board[src + 8] and castle &= board[dst + 8], read the masks stored in the board's right-hand padding. Most padding entries contain 15, preserving every right; the six special masks correspond to the kings' and rooks' original squares. This automatically clears castling rights when a king or rook moves, or when a rook is captured on its starting square. The old value is restored after a temporary move is undone, so simply generating moves does not permanently change castling rights.
4. Promotions and recording moves
The first if (pceType == 2) checks whether the moving piece is a pawn. If it reaches the final rank, board[dst] = ((uProm > 0) ? uProm : 6) | side; temporarily replaces it with the requested promotion piece, or a queen when no promotion choice was supplied. The loop for (let p = 6; p > 2; p--) adds the four promotion choices—queen, rook, bishop, and knight—to the move list. Each entry includes capPce, the captured piece, so later search code can recognize captures without reconstructing that information. This is particularly useful in quiescence search, where the engine extends its search through tactical moves such as captures. If the pawn is not promoting, the function records an ordinary move with promotion value zero. Non-pawn moves are recorded in the same four-element format.
5. Executing a requested move
if (src == uSrc && dst == uDst) checks whether the candidate matches the move requested by the caller. If it does, the function updates the en passant target. It first clears ep, then checks whether a pawn has advanced two squares; if so, ep = dst ^ 16 identifies the square behind that pawn. For a king move, the subsequent conditions assign the appropriate intermediate square to ep when the move is castling. In this compact engine, that global variable is also reused by the castling-checking logic described earlier.
side = 24 - side; switches the side to move: with the two side values 8 and 16, subtracting either from 24 produces the other. The function then returns 'move', leaving the requested move applied to the board. This is how the interface and search can use the same generator to make a move rather than just list possible moves.
6. Undoing the candidate and checking special rules
If the candidate was not the requested move, the next line restores the board: board[dst] = 0; board[src] = pce; board[capSq] = capPce; removes the temporary destination piece, returns the mover to its source, and restores the captured piece. The second castling switch reverses the rook movement performed earlier. Then castle = oldCastle; restores the castling rights saved before testing the move. Together, these operations let the function explore many possibilities without leaving the board in a changed state.
The following pawn conditions restrict the pawn's extra forward step to its starting rank and prevent it from advancing a second square after the first step. For kings, the code checks the direction and whether castling is possible. A king moving in the positive direction must have the relevant castling right and an empty square two positions away; moving in the negative direction also requires the extra square to be empty, because queenside castling needs more room. The color-specific bit tests use the engine's castling-right encoding. The source-square test ensures that castling can only begin from the king's original square, while Math.abs(src-dst) > 1 stops the king from continuing along the same direction after its ordinary one-square move.
if (capPce & (24 - side)) break; stops further exploration when the candidate captures an opposing piece. The next condition, if (!(pceType & 4) && pceType != 2 && pceType != 1) break;, stops pieces that do not slide, except pawns and kings, whose movement rules have already been handled separately. Sliding pieces continue with dst += dir, advancing to the next square in the current direction. Once all directions and all pieces have been processed, return moves; returns the generated pseudolegal move list.
Why reuse one function?
The move generator produces pseudolegal moves, can execute a requested move, tracks king captures for attack and legality checks, handles special moves, and records captured pieces for quiescence search. It also relies on the global ep and castle variables, temporarily updating and restoring them instead of threading extra state through every call. This keeps the interface simple and avoids duplicating much of the chess rules across separate functions.
The Search Algorithm
The search function is where our engine turns the move generator into a chess-playing program. It explores possible moves, evaluates the resulting positions, and tries to find the move that gives the best result against an opponent who also plays optimally. To keep this process efficient, we combine several techniques: negamax search with alpha-beta pruning, iterative deepening from the user interface, quiescence search, principal variation tracking, and a node limit that lets the engine stop when its search budget is exhausted.
// Engine
function search(depth, ply, alpha, beta, caps, iid) {
if (stop) return 0;
if (!ply && !iid) {
var start = Date.now(); nodes = 0;
pvTable = Array.from({ length: 64 }, () => Array(64).fill(0));
pvLength = new Array(64).fill(0);
} pvLength[ply] = ply; if (depth <= 0) {
if (nodes > 250000) stop = 1;
let score = 0; let sq = -1; while (++sq < 120) {
if (sq & 0x88) continue;
let pce = board[sq];
if (pce) {
let value = material[pce & 7] + pst[sq];
(pce & 8) ? (score += value) : (score -= value);
if (pce == 9 && (sq == 114 || sq == 118)) score += 20;
if (pce == 17 && (sq == 2 || sq == 6)) score -= 20;
}
} score = (side == 8) ? score : -score;
if (score >= beta) return beta;
if (score > alpha) alpha = score;
caps = 1;
} nodes++; let moves = movegen(-1, -1, -1);
if (depth >= 5 && !caps) {
search(depth-2, ply, alpha, beta, caps, 1);
if (stop) return 0;
} for (let current = 0; current < moves.length; current++) {
if (moves[current][0] == pvTable[ply][ply][0] &&
moves[current][1] == pvTable[ply][ply][1] &&
moves[current][2] == pvTable[ply][ply][2]) {
let temp = moves[0];
moves[0] = moves[current];
moves[current] = temp;
break;
}
} for (let move of moves) {
if (caps && !move[3]) continue;
let oldBoard = []; let oldSide = side, oldEp = ep, oldCastle = castle;
for (let sq = 0; sq < 128; sq++) oldBoard[sq] = board[sq];
movegen(move[0], move[1], move[2]);
if (!movegen(-1, -1, -1).length) {
for (let sq = 0; sq < 128; sq++) board[sq] = oldBoard[sq];
side = oldSide, ep = oldEp, castle = oldCastle;
continue;
} let nextDepth = caps ? 0 : depth - 1;
let score = -search(nextDepth, ply + 1, -beta, -alpha, caps, 0);
for (let sq = 0; sq < 128; sq++) board[sq] = oldBoard[sq];
side = oldSide, ep = oldEp, castle = oldCastle;
if (stop) return 0;
if (score > alpha) {
alpha = score;
pvTable[ply][ply] = move;
for (let i = ply + 1; i < pvLength[ply + 1]; i++) pvTable[ply][i] = pvTable[ply + 1][i];
pvLength[ply] = pvLength[ply + 1];
if (score >= beta) return beta;
}
} if (!ply && !iid) {
let pv = "";
for (let i = 0; i < pvLength[0]; i++) {
let move = pvTable[0][i];
pv += String.fromCharCode(97 + (move[0] & 7)) + (8 - (move[0] >> 4)) +
String.fromCharCode(97 + (move[1] & 7)) + (8 - (move[1] >> 4)) +
(move[2] ? String.fromCharCode(97 + move[2]) : "");
if (i < pvLength[0]-1) pv += " ";
} console.log(`score ${alpha} depth ${depth} nodes ${nodes} time ${Date.now() - start} pv ${pv}`);
} return alpha;
}
The function accepts six arguments: depth, the remaining search depth; ply, the number of half-moves from the root; alpha and beta, the bounds used by alpha-beta pruning; caps, which indicates that we are searching captures in quiescence mode; and iid, a flag used for an auxiliary search that helps identify a promising move before the main search. These parameters allow the same function to handle ordinary search, quiescence search, and internal iterative deepening without separate implementations.
1. Starting and stopping the search
if (stop) return 0;
if (!ply && !iid) {
var start = Date.now(); nodes = 0;
pvTable = Array.from({ length: 64 }, () => Array(64).fill(0));
pvLength = new Array(64).fill(0);
}
pvLength[ply] = ply;
The first line checks the global stop flag. If the engine has been asked to stop, the function returns immediately instead of spending more time exploring moves. At the root of a normal search—where ply is zero and iid is false—we record the start time and reset the node counter. We also initialize pvTable, a two-dimensional array that stores the current principal variation, and pvLength, which records the length of the variation at each search ply. A principal variation (PV) is the sequence of moves the engine currently considers best. The line pvLength[ply] = ply initializes the variation length for the current search level.
The engine uses a maximum of 64 plies for its PV arrays, which is ample for the intended search depth. Resetting these structures at the beginning of each root search prevents information from a previous iteration from being mistaken for the current principal variation.
2. Evaluating a position at the search horizon
if (depth <= 0) {
if (nodes > 250000) stop = 1;
let score = 0; let sq = -1; while (++sq < 120) {
if (sq & 0x88) continue;
let pce = board[sq];
if (pce) {
let value = material[pce & 7] + pst[sq];
(pce & 8) ? (score += value) : (score -= value);
if (pce == 9 && (sq == 114 || sq == 118)) score += 20;
if (pce == 17 && (sq == 2 || sq == 6)) score -= 20;
}
}
score = (side == 8) ? score : -score;
if (score >= beta) return beta;
if (score > alpha) alpha = score;
caps = 1;
}
When depth <= 0, the search reaches its nominal horizon. Instead of stopping immediately, it evaluates the current position and switches to capture-only search. This is the beginning of quiescence search: by continuing to examine captures, the engine can avoid evaluating a position in the middle of an obvious exchange. The node counter is also checked against 250000; once that threshold is exceeded at this point, the global stop flag is set so the search can unwind.
A node limit is often more practical than a fixed depth limit when we do not implement time management, because the number of positions grows unevenly: some positions have very few legal moves, while others have many. A fixed depth can therefore take unpredictable amounts of time to complete, especially as the search gets deeper. Limiting the number of visited nodes gives the engine a more consistent computational budget, regardless of how branching varies across positions. It also allows iterative deepening to complete as many search iterations as possible within that budget, although the exact runtime can still vary with the cost of processing each node.
The evaluation starts at zero and scans the board using the same 0x88 representation as the move generator. Padding squares are skipped, and each occupied square contributes its material value from material plus its positional value from pst. Pieces belonging to White add to the score; pieces belonging to Black subtract from it. The two extra conditions give a small bonus or penalty when the specified king occupies a castling destination square. This rewards those particular king positions in the evaluation, in addition to the separate castling rules.
Because the rest of the search uses negamax, the score is converted to the perspective of the side to move: if side == 8, the score is kept as it is; otherwise, its sign is reversed. If the evaluation is already at least beta, the function returns beta, since this branch cannot improve the result for the current caller. Otherwise, alpha is raised when the evaluation is better than the current lower bound. Finally, caps = 1 switches the search into capture-only mode.
3. Generating moves and improving move ordering
nodes++;
let moves = movegen(-1, -1, -1);
if (depth >= 5 && !caps) {
search(depth-2, ply, alpha, beta, caps, 1);
if (stop) return 0;
}
Every search node increments nodes, giving us a measure of how much work the engine has performed. The call to movegen(-1, -1, -1) generates the current side's pseudolegal moves without requesting that any particular move be executed. When the search reaches depth five or more, and is not already in capture-only mode, it performs an additional, shallower search at depth - 2. The final argument, iid = 1, marks this as an internal iterative deepening (IID) search. Its purpose is to help establish a promising principal-variation move that can be searched first in the main search. If the auxiliary search triggers the stop flag, we return immediately.
This is useful because alpha-beta pruning works best when strong moves are examined early. Finding a good candidate move before the full search can therefore reduce the amount of work required. In this implementation, the auxiliary search shares the current position and ply, and its results are used through the principal variation table.
for (let current = 0; current < moves.length; current++) {
if (moves[current][0] == pvTable[ply][ply][0] &&
moves[current][1] == pvTable[ply][ply][1] &&
moves[current][2] == pvTable[ply][ply][2]) {
let temp = moves[0];
moves[0] = moves[current];
moves[current] = temp;
break;
}
}
This loop looks for the move already stored at the beginning of the current ply's principal variation. It compares the source square, destination square, and promotion choice. If it finds a match, it swaps that move with the first entry in moves, then stops searching. As a result, the previously preferred move is examined first. The temporary variable temp preserves the original first move during the swap. This is a compact form of PV move ordering: rather than sorting every move by a score, we give the engine's known promising move priority.
4. Searching candidate moves
for (let move of moves) {
if (caps && !move[3]) continue;
let oldBoard = [];
let oldSide = side, oldEp = ep, oldCastle = castle;
for (let sq = 0; sq < 128; sq++) oldBoard[sq] = board[sq];
movegen(move[0], move[1], move[2]);
if (!movegen(-1, -1, -1).length) {
for (let sq = 0; sq < 128; sq++) board[sq] = oldBoard[sq];
side = oldSide, ep = oldEp, castle = oldCastle;
continue;
}
The main loop visits each candidate move. When caps is true, if (caps && !move[3]) continue; skips moves that do not capture anything. The fourth element of each move, move[3], contains the captured piece, which is why the move generator records it when building the move list. This filtering turns the search into quiescence search, where the engine examines captures instead of continuing through every quiet move.
Before making a move, the function saves the current board and the global state variables side, ep, and castle. The loop copies all 128 array entries, including the unused 0x88 padding. It then calls movegen(move[0], move[1], move[2]) to execute the candidate move. A second call to movegen(-1, -1, -1) generates the opponent's pseudolegal moves. If that list is empty, the candidate is rejected and the saved position and state are restored before the loop continues.
This legality check relies on the engine's king-capture approach: the move generator returns an empty list when it encounters a move that captures a king. Thus, an empty opponent move list is used here as a signal that the candidate should not be searched.
5. Negamax and alpha-beta pruning
let nextDepth = caps ? 0 : depth - 1;
let score = -search(nextDepth, ply + 1, -beta, -alpha, caps, 0);
for (let sq = 0; sq < 128; sq++) board[sq] = oldBoard[sq];
side = oldSide, ep = oldEp, castle = oldCastle;
if (stop) return 0;
The next depth depends on whether we are in capture-only mode. During ordinary search, depth - 1 advances the search one ply deeper. During quiescence search, nextDepth remains zero, so the recursive call continues evaluating positions while the capture filter remains active.
The recursive call uses the negamax formulation of minimax. Since the evaluation is always expressed from the current side's perspective, the opponent's score is the negative of our score. We therefore negate the returned value and reverse the alpha-beta bounds: -beta becomes the new lower bound, and -alpha becomes the new upper bound. This lets the same search code handle both players without separate maximizing and minimizing branches.
After recursion returns, the original board and global state are restored. This is essential because each candidate must be evaluated from the same starting position. If the global stop flag has been set, the function immediately returns zero to unwind the remaining recursive calls.
if (score > alpha) {
alpha = score;
pvTable[ply][ply] = move;
for (let i = ply + 1; i < pvLength[ply + 1]; i++)
pvTable[ply][i] = pvTable[ply + 1][i];
pvLength[ply] = pvLength[ply + 1];
if (score >= beta) return beta;
}
When a move produces a score better than the current alpha, the engine improves its lower bound and records the move as the best one found so far. It places the move at the beginning of the current row in pvTable, then copies the remainder of the child position's principal variation into the current row. The pvLength array is updated to reflect the length of that variation. This builds a sequence of moves representing the engine's current best line.
The final condition, if (score >= beta) return beta;, performs an alpha-beta cutoff. Once the score reaches or exceeds the upper bound, examining more moves at this node cannot improve the result relevant to the parent. Returning beta immediately saves the engine from searching branches that can no longer affect the decision. The combination of alpha-beta pruning and searching promising moves early is one of the main reasons move ordering matters so much.
6. Reporting the principal variation
if (!ply && !iid) {
let pv = "";
for (let i = 0; i < pvLength[0]; i++) {
let move = pvTable[0][i];
pv += String.fromCharCode(97 + (move[0] & 7)) + (8 - (move[0] >> 4)) +
String.fromCharCode(97 + (move[1] & 7)) + (8 - (move[1] >> 4)) +
(move[2] ? String.fromCharCode(97 + move[2]) : "");
if (i < pvLength[0]-1) pv += " ";
}
console.log(`score ${alpha} depth ${depth} nodes ${nodes} time ${Date.now() - start} pv ${pv}`);
}
return alpha;
Once the root search finishes, the function prints its results, provided this is not an internal iterative deepening call. The !ply && !iid condition restricts reporting to the root of a normal search, so recursive calls do not flood the console with output.
The code builds a string containing the principal variation. For every move in pvTable[0], it converts the source and destination squares into coordinate notation. The expression move[0] & 7 extracts the file, and move[0] >> 4 extracts the rank index; the same operations are applied to the destination. Adding 97 converts a file index into the character codes for a through h, while 8 - (square >> 4) converts the internal rank index into chess notation. If the move is a promotion, the final expression appends a letter based on the promotion value. Spaces separate moves in the resulting line.
The console output includes the current score, search depth, number of nodes visited, elapsed time in milliseconds, and principal variation. This information is valuable when debugging and tuning the engine: we can compare how many nodes different searches visit, how long they take, and which line the engine believes is best. Finally, return alpha; returns the best lower bound found by the search to its caller.
The search in a nutshell
Our search repeatedly generates moves, makes each candidate, evaluates the resulting position recursively, and restores the original state. Negamax simplifies the alternating turns, alpha-beta pruning avoids unnecessary branches, quiescence search examines captures beyond the nominal depth, and the principal variation records the strongest line found. The global stop flag and node limit provide a simple way to interrupt an expensive search, while the root diagnostics help us measure its performance.
Bonus Chapter: Perft Testing
This style of move generation has a well-known counterpart in H. G. Muller's Micro-Max, a remarkably compact chess engine that combines move generation and search within a single recursive function. While this approach saves code, it makes isolated move-generation testing more difficult because the two responsibilities are intertwined. Our engine takes a more conventional approach, keeping move generation separate from search, so we can test it independently with perft and compare its node counts against known reference results. This makes it much easier to detect and debug errors in legal move generation without having to untangle the search itself.
What Is Perft?
Perft, short for performance test, counts the number of leaf positions reached by recursively generating every legal move to a specified depth. At depth one, we count all legal moves from the current position. At depth two, we count every legal reply to those moves. At depth three, we continue one ply further, and so on. A ply is one move by one side, so a depth of four means four individual moves, not four complete turns.
Perft does not evaluate positions, choose good moves, or attempt to play chess. It simply explores the move tree and counts its leaves. Because standard chess positions have well-known perft results, we can compare our engine's counts against trusted reference values. A mismatch tells us that something is wrong in move generation or position handling, even if the engine's gameplay looks reasonable.
Printing the Board
We begin with a small debugging helper that prints the board and the current node count. It is useful when investigating a suspicious position, because seeing the board is often much easier than trying to reason about a sequence of square indices.
function print() {
let b = "";
for (let r = 0; r < 8; r++) {
for (let c = 0; c < 8; c++) {
let sq = r*16+c;
if (sq & 0x88) continue;
b += ".kpnbrq--KPNBRQ"[board[sq]&15];
} b += "\n";
} console.log(b, nodes);
}
The function builds a string named b to represent the board. The outer loop visits eight ranks, and the inner loop visits eight files on each rank. The expression r*16+c converts the rank and file into our engine's 0x88 square index. The if (sq & 0x88) check skips indices outside the playable board, although with these loop bounds every generated square is already valid; it is retained as a reminder of the 0x88 representation.
The string ".kpnbrq--KPNBRQ" maps the low four bits of each board value to a printable character. Empty squares appear as dots, black pieces as lowercase letters, and white pieces as uppercase letters. The two hyphens occupy unused entries in the lookup string, while board[sq] & 15 selects the character associated with the stored piece code. After each rank, we append a newline, then print the completed board alongside nodes.
The Recursive Perft Driver
The core of the test is perftDriver(). It recursively explores the legal moves and increments the global node counter whenever it reaches the requested depth.
nodes = 0; function perftDriver(depth, ply) {
if (!depth) { nodes++; return; }
for (let move of movegen(-1, -1, -1)) {
let oldBoard = []; let oldSide = side, oldEp = ep, oldCastle = castle;
for (let sq = 0; sq < 128; sq++) oldBoard[sq] = board[sq];
movegen(move[0], move[1], (move.length == 3 ? move[2] : -1));
if (!movegen(-1, -1, -1).length) {
for (let sq = 0; sq < 128; sq++) board[sq] = oldBoard[sq];
side = oldSide, ep = oldEp, castle = oldCastle;
continue;
} perftDriver(depth - 1, ply+1);
for (let sq = 0; sq < 128; sq++) board[sq] = oldBoard[sq];
side = oldSide, ep = oldEp, castle = oldCastle;
}
}
We initialize nodes to zero before defining the driver. The depth argument determines how many plies remain, while ply records how far we have descended from the starting position. ply is not actually used in this implementation but it shows how to keep this variable inside the recursive function. Previously, in search we used it to index our PV table.
The first condition, if (!depth), is the base case. When the remaining depth reaches zero, we increment nodes and return. This means every leaf reached by the search contributes exactly one to the total.
Otherwise, we ask movegen(-1, -1, -1) for the current position's candidate moves. For each move, we save the complete board and the global state that can change: side, ep, and castle. The loop copies all 128 entries, including the padding used by our 0x88 representation.
Next, movegen(move[0], move[1], (move.length == 3 ? move[2] : -1)) applies the candidate move. The conditional expression passes the promotion piece when the move has three entries; otherwise, it passes -1 to indicate that no promotion argument is supplied. This matches the move format expected by this version of the engine.
We then generate the opponent's moves. If the resulting list is empty, this engine's king-capture shortcut treats the candidate as illegal: we restore the board and global state, then continue to the next candidate. For a legal move, we call perftDriver(depth - 1, ply+1), reducing the remaining depth by one ply and increasing the ply counter.
Finally, we restore the saved board and global state after recursion returns. This restoration is essential: each sibling move must start from the same parent position. Without it, one branch could accidentally inherit pieces, en-passant state, or castling rights changed by another branch, making the node count meaningless.
A note about move generation
This driver depends on the engine's existing move-generation conventions. In particular, it uses an empty opponent move list as the signal for an illegal candidate, relying on the engine's king-capture shortcut. When adapting the test to another engine, use that engine's own legality-checking mechanism instead. A conventional engine may distinguish checkmate, stalemate, and illegal positions explicitly.
Testing Every Root Move
The recursive driver returns only a total node count. To make debugging more useful, our top-level perftTest() function also counts the nodes reached through each individual move from the starting position. This is called a divide-style perft test: instead of reporting only one large number, it shows how much each root move contributes.
function perftTest(depth) {
nodes = 0;
console.log(' Performance test:\n');
resultString = '';
let startTime = Date.now();
let count = 0;
for (let move of movegen(-1, -1, -1)) {
let oldBoard = JSON.stringify(board), oldSide = side, oldEp = ep, oldCastle = castle;
movegen(move[0], move[1], (move.length == 3 ? move[2] : -1));
if (!movegen(-1, -1, -1).length) {
board = JSON.parse(oldBoard), side = oldSide, ep = oldEp, castle = oldCastle;
continue;
} let cumNodes = nodes;
perftDriver(depth - 1, 0);
board = JSON.parse(oldBoard), side = oldSide, ep = oldEp, castle = oldCastle;
let oldNodes = nodes - cumNodes; count++;
console.log(
' move' +
' ' + (count) + ((count < 9) ? ': ' : ': ') + String.fromCharCode(97 + (move[0] & 7)) +
(8 - (move[0] >> 4)) + String.fromCharCode(97 + (move[1] & 7)) + (8 - (move[1] >> 4)) +
' nodes: ' + oldNodes
);
}
resultString += '\n Depth: ' + depth;
resultString += '\n Nodes: ' + nodes;
resultString += '\n Time: ' + (Date.now() - startTime) + ' ms\n';
console.log(resultString);
} //perftTest(4); ui();
At the start of perftTest(), we reset the node counter, print a heading, clear resultString, record the start time, and initialize a counter for the root moves. The function then loops through the moves generated from the current position.
For each root move, the code saves the board by converting it to JSON and saves the three global state variables separately. It applies the move and uses the same opponent-move check as the recursive driver to reject illegal candidates. If the candidate is rejected, the saved state is restored and the loop continues.
For a legal move, cumNodes stores the node count before searching its subtree. We then call perftDriver(depth - 1, 0), because the root move has already consumed one ply. When the recursive call finishes, the board and state are restored. Subtracting cumNodes from the new total gives oldNodes, the number of leaf nodes contributed by that particular move.
The output formats the root move in coordinate notation. String.fromCharCode(97 + (move[0] & 7)) converts the source file index to a letter from a to h, while 8 - (move[0] >> 4) converts the rank index to a chessboard rank. The same calculations are applied to the destination square. The move number is padded with an extra space for single-digit entries so the results line up in the console.
After all root moves have been processed, the function appends the requested depth, total node count, and elapsed time in milliseconds to resultString, then prints the summary. The elapsed time is calculated by subtracting the recorded start time from Date.now().
Why save the board differently here?
The recursive driver copies the 128 board entries into an array, while the root test uses JSON.stringify() and JSON.parse() to save and restore the board. Both approaches are intended to preserve the board state, but they are not equally efficient. JSON serialization adds overhead, so for a performance-focused test we could use the same array-copy approach in both functions. Here, the more important point is that the root test needs to restore the position before moving on to the next root move.
Running the Test
The final comment, //perftTest(4); ui();, shows how to invoke the test.
For a standard initial chess position, widely used reference counts are 20 nodes at depth one, 400 at depth two, 8,902 at depth three, and 197,281 at depth four. Compare our totals against these values using the same initial position and the same definition of legal moves. If the total differs, examine the per-move counts to narrow down which branch first diverges. Special moves deserve particular attention: castling, en passant, promotion, and moves that leave the king in check are common sources of errors.
One final detail is worth remembering: perft is a correctness test first and a speed test second. The time reported by this code can help us measure changes, but it is affected by the runtime environment and by the overhead of copying and restoring state. Correct node counts are the essential result; speed comparisons become meaningful only when the same positions, depth, and testing conditions are used.
Project Summary
We have built a compact chess engine in JavaScript, designed to fit alongside its interface in a single HTML file. The board uses the 0x88 representation to make square-boundary checks simple, while compact piece codes, movement offsets, material values, and piece-square tables keep the core data structures small. Our move generator handles candidate moves and special chess rules, and the search function uses negamax with alpha-beta pruning, quiescence search, principal-variation move ordering, and a node-based stopping mechanism to find a playable move.
We have also seen how careful reuse of global state and compact bit masks can reduce duplicated logic, especially when updating castling rights and testing move legality. Finally, perft testing gives us a practical way to validate move generation by comparing generated node counts with known reference results and examining individual root moves when something goes wrong.
The goal was never to reproduce every feature of a large tournament engine. It was to understand the essential parts of a chess engine, see how those parts fit together, and make deliberate trade-offs between code size, clarity, speed, and strength. With search, a user interface, and a repeatable testing method in place, our small engine is not just a program that can play chess—it is a project we can measure, debug, and continue improving.