Show HN: Chess in SQL A developer created Quack-Mate, a chess engine built entirely in pure SQL using DuckDB, demonstrating that a modern analytical database can be pushed to play chess despite the paradigm mismatch. The engine runs in a browser via WebAssembly or locally with Node.js, and it exposes real-time SQL queries and search statistics. The project explores the limits of set-based execution for tree-search problems like chess. Quack-Mate Pushing the Boundaries of Pure SQL Chess Image generated with Google Gemini I know what you’re thinking: SQL is a terrible language for a chess engine. And you’re right. It is inherently designed for set-based data retrieval, not for the highly branching, depth-first search of chess engines. My intention with Quack-Mate wasn’t to dethrone Stockfish, but to explore a single, slightly mad question: just how far can we push a modern analytical database to play chess? TL;DR:Is it possible to build a playable chess engine in pure SQL? Though it’s not trivial, the answer is “yes, with conditions”.Quack-Mateexplores the inevitable collision between the set-based execution models of database engines and the sequential, depth-first reasoning required for efficient chess. A playable compromise between these two opposing forces requires trading off both the algorithmic superiority of traditional engines and the raw data-crunching throughput of modern analytical databases. The result is a functional engine that proves an ‘unsuitable’ paradigm can be stretched to perform—and a clear-eyed look at exactly why the divide between sets and trees runs so deep. Here is the Quack-Mate user interface in action. You can play the WebAssembly WASM https://swingbit.github.io/quack-mate/ version online in your browser not from your phone, sorry , or run the interface locally https://github.com/swingbit/quack-mate using the native Node.js DuckDB driver for a significant performance boost. As the engine thinks, the interface exposes the exact SQL queries executing under the hood in real-time, alongside checkboxes to toggle specific move ordering or lossy pruning heuristics and a complete breakdown of detailed search statistics: If you have played a few games and explored the source code, you might wonder why anyone would choose to build a chess engine this way. For me, it began with a simple realisation: it seemed nobody had done it before—at least, not like this. While there have been a few attempts to implement chess in databases, they typically rely heavily on procedural extensions like Oracle’s PL/SQL or PostgreSQL’s PL/pgSQL with explicit loops and variables , or they are written as C extensions. Implementing a fully functioning chess engine purely through relational algebra and standard SQL queries on a modern analytical engine like the brilliant DuckDB felt like uncharted territory. Modern analytical engines like DuckDB are absolute beasts at crunching numbers in high volumes. Exploring the immense tree of chess possibilities immediately brings to mind joining a table of ‘boards’ with a table of ‘all possible moves’ to create a new generation of boards. If a pure SQL formulation is possible, you get the tremendous benefits of advanced database engines for free: brutal query optimisation and vectorised parallelisation over millions of rows. But why is raw efficiency so heavily emphasised in chess programming? It comes down to a computational challenge known as combinatorial explosion. From the starting position, White has 20 possible moves, and Black has 20 responses, meaning there are 400 possible games after just one full turn. After three full turns, there are over 119 million possible games. After just four full turns 8 plies , the game tree explodes to roughly 85 billion possible games To play well, an engine must look as far ahead into these exponentially growing branches as possible. Therefore, in chess engines, speed directly translates to search depth, and depth translates directly into playing strength. Anatomy of a Chess Engine and How SQL Handles It Before diving into the complex queries, it helps to understand the basic components of a traditional chess engine and how Quack-Mate translates them into the language of relational databases. Board Representation & Bitboards The absolute foundation of any engine is how it “sees” the game. A highly optimised state representation is critical because the engine will need to store, copy, and evaluate millions of these states per second during a deep search. Before we talk about moving pieces, we have to talk about how the board is stored. A chess board has 64 squares. By a happy coincidence of computing history, modern CPU registers are standardly 64 bits wide. Modern chess engines use Bitboards : 64-bit integers where each bit represents a square on the board 1 if occupied, 0 if empty . Instead of keeping one big array that says “Square E4 houses a White Pawn”, engines maintain separate bitboards for each piece type. You need 12 in total: White Pawns, White Knights, Black Pawns… all the way to Black Kings. Using Bitboards means answering chess questions becomes highly efficient bitwise math. All the White Pawns are identified by a specific bitboard: wP bb Where are all the white pieces? w bb = wP bb | wN bb | ... | wK bb Is that square empty? w bb | b bb & square mask == 0 Want to flip the presence of a White Knight on a specific square? wN bb ^ square mask This is how modern engines evaluate positions millions of times per second. To replicate this state-of-the-art representation in a database, we hit an immediate wall. Standard SQL does not have unsigned 64-bit integers. One option is to use SQL bitstring types like PostgreSQL’s BIT 64 , which natively allow manipulating 64 bits correctly. However, these are generally much less efficient than native integer types when performing large-scale operations. Alternatively, we could use a standard signed BIGINT like int8 in PostgreSQL . However, a signed 64-bit integer uses the 64th bit as the sign bit. The primary issue this introduces is with the right-shift operator , which performs an arithmetic shift propagating the sign bit instead of a logical shift. Painstakingly using a signed BIGINT is possible, but it requires injecting computationally expensive masking conditions into every query just to isolate and handle the sign bit correctly. Alternatively, you could reach for larger or non-standard SQL data types, but this solution isn’t optimal either: ClickHouse : The leader in this space, offering native 128-bit UInt128 and even 256-bit unsigned integers. MonetDB : Offers a native 128-bit HUGEINT type. An early prototype of Quack-Mate actually supported both DuckDB and MonetDB using the HUGEINT type , as they both are high-performance analytical engines. Snowflake : Its primary numeric type is NUMBER , but its internal engine and bitwise functions like BITAND , BITSHIFTLEFT actually operate on and return signed 128-bit integers. PostgreSQL via Extension : While lacking native support in core, the specialised pg-uint128 extension adds unsigned 128-bit integers which can introduce query execution overhead when processing operations over non-native extension types . This is precisely where DuckDB shines. I eventually focussed on it because it hits the exact sweet spot: it provides native UBIGINT support to avoid 128-bit overhead, while its high-performance analytical engine allows us to process massive game trees entirely in-process. While a few other databases also support unsigned 64-bit integers such as MySQL, MariaDB, and ClickHouse , DuckDB’s unique architecture provides the perfect environment for a SQL-based engine to prove its worth. By storing the entire game state as a single database row containing 12 UBIGINT columns, we can finally translate chess computations into pure, vectorised SQL operations. The mechanical efficiency of bitboards is most striking when moving a piece down the search tree. Instead of looping over a traditional array to painstakingly clear “Square A1” and write to “Square A2”, a move mathematically distils down to two simple square-presence flips. By applying two bitwise XOR operations against the original bitboard—one to toggle off the “from” square, and one to toggle on the “to” square—the piece instantly teleports to its new destination. In short: board ^= square from | square to : Because DuckDB supports these bitwise operators natively on unsigned integers, the analytical engine can execute these binary flips across millions of rows simultaneously: -- Applying a move to the white pawns bitboard SELECT -- XORing the combined OR'd masks toggles both squares at once xor s.wP bb, deltaFrom mask | deltaTo mask AS wP bb FROM current states s -- This happens concurrently for all 12 piece column types Pseudo-Move Generation To look into the future, the engine must systematically generate all possible next moves from a given position. Building the massive tree of variations starts here. These generated moves are “pseudo-legal”. This means they follow the basic geometric movement rules of the pieces e.g., a bishop moving diagonally , but they don’t yet account for complex board state rules, like whether making that move would illegally expose the player’s own King to check. The Imperative Approach While modern engines don’t naively loop over all 64 squares, they do iterate piece-by-piece. 🛠️ Click to expand technical details They grab the bitboard for a specific piece type like Knights , use hardware instructions to find the first set bit the square the piece is on , and then loop over its pre-calculated “mobility mask”—a bitboard showing every legally reachable square from that position regardless of whether an enemy piece is there or not—to generate moves. // Generating pseudo-legal moves imperative bitboard engine U64 knights = board.white knights bb; // Loop over every knight we have while knights { // Hardware instruction finds the piece's square index int sq = pop lsb &knights ; // Look up the mobility mask and loop over every reachable target square U64 valid destinations = KNIGHT MOBILITY sq & ~board.own pieces bb; while valid destinations { int target sq = pop lsb &valid destinations ; add move move list, sq, target sq ; } } // ... repeat this entire process for Bishops, Rooks, Queens, etc. The Relational Approach Generating next moves isn’t done piece-by-piece; it’s a massive, concurrent JOIN operation. Quack-Mate supports all standard legal chess moves—including castling and pawn promotions—with the sole exception of the en-passant rule. While essential for full FIDE compliance, tracking the transient history-dependent state required for en-passant in pure SQL adds substantial query and schema complexity to the move generator. To keep the relational joins and database schema as clean and streamlined as possible, en-passant is currently omitted from the move generator. 🛠️ Click to expand technical details We pre-compute these masks for every piece on every square and store them as two static lookup tables: mobility precomputed showing where a piece can legally move and attacks precomputed a separate table necessary specifically for Pawns, which move forward but capture diagonally . We explode the current game state out into all its pieces and join them with the pre-computed mobility tables to instantly spawn rows for every possible pseudo-legal continuation for all pieces simultaneously. The JOIN LATERAL pattern acts as our primary move generator. It iterates over a 64-row squares table, using a nested CASE WHEN to check the active turn at the top level. This allows DuckDB to instantly short-circuit and skip checking the inactive player’s bitboards entirely. Active piece squares are identified and directly joined to mobility precomputed on unsigned piece equality mp.piece = pt.piece to leverage static indices, while empty squares are filtered instantly at the join boundary via pt.piece IS NOT NULL . SELECT s.id AS parent id, sq.i AS from sq, m.target sq AS to sq, pt.piece s.active turn ::TINYINT AS piece FROM squares sq -- CROSS JOIN s -- when bulk processing -- 1. Explode: Find occupied squares and identify pieces using a nested turn check JOIN LATERAL SELECT CASE WHEN s.active turn = 1 THEN CASE WHEN is bit set s.wN bb, sq.i THEN 2 WHEN is bit set s.wB bb, sq.i THEN 3 WHEN is bit set s.wR bb, sq.i THEN 4 WHEN is bit set s.wQ bb, sq.i THEN 5 WHEN is bit set s.wK bb, sq.i THEN 6 END ELSE CASE WHEN is bit set s.bN bb, sq.i THEN 2 WHEN is bit set s.bB bb, sq.i THEN 3 WHEN is bit set s.bR bb, sq.i THEN 4 WHEN is bit set s.bQ bb, sq.i THEN 5 WHEN is bit set s.bK bb, sq.i THEN 6 END END AS piece pt ON pt.piece IS NOT NULL -- 2. Join Mobility JOIN mobility precomputed m ON m.from sq = sq.i AND m.piece = pt.piece WHERE m.ray mask & s.all pieces bb = 0 AND NOT is bit set s.my pieces bb, m.target sq UNION ALL -- 3. Generate pawn captures separately using attack masks SELECT s.id AS parent id, sq.i AS from sq, a.target sq AS to sq, 1 AS piece FROM current states s JOIN LATERAL SELECT 1 AS piece WHERE is bit set s.wP bb, sq.i p ON true JOIN attacks precomputed a ON a.from sq = sq.i AND a.piece = p.piece -- Pawn attacks are only valid legal moves if an opponent piece is actually there to capture WHERE is bit set s.opponent pieces bb, a.target sq Is the King in Check? Move Validation Chess rules forbid making a move that leaves your own King under attack. “Pseudo-move generation” creates moves based on piece logic, but “validation” acts as the strict filter that tosses out the illegal ones before they pollute the search tree. Checking legality normally requires calculating complex “pin masks”. SQL is terrible at this. To bypass it, we reverse the math: instead of tracking protecting pieces, we check if our King could theoretically attack the enemy piece in return. The Imperative Approach Modern engines use fast bitwise math on the current board state to check legality, rather than making and un-making moves. 🛠️ Click to expand technical details While older or simpler engines might literally “make the move, check if the king is attacked, take it back,” this is way too slow for a modern engine. Modern engines pre-calculate an “absolute pin mask” for pieces defending the king and evaluate if a proposed move violates a pin or places the King onto an attacked square, usually doing this validation lazily right before the move is searched. // Fast bitwise legality test on the current state modern imperative bool is legal Board board, Move move { if piece type move == KING { return is square attacked board, to sq move , opponent ; } // If piece is pinned, it can only move along the pinning ray. // A jumping Knight's move will never align, cleanly returning false . if board.pinned mask & 1ULL << from sq move { return aligned from sq move , to sq move , board.king sq ; } // ... en-passant edge cases return true; } The Relational Approach Quack-Mate embraces the brute force of set theory by applying all moves simultaneously and then filtering the illegal ones via a “backwards” attack check. 🛠️ Click to expand technical details Calculating absolute pin masks dynamically in pure SQL is incredibly inefficient. Instead, Quack-Mate skips pre-validation: we apply all pseudo-legal moves in bulk to spawn a CTE of expanded states , then filter the illegal boards. While classical engines maintain a single board state and sequentially “make” and “un-make” moves in a loop to conserve memory, SQL excels at generating massive sets of independent, immutable rows simultaneously. To filter out the illegal states without complex pin-masks, we check legality concurrently using a “backwards” attack check: we trace attacks in reverse starting from the King’s square. By executing an EXISTS subquery that bitwise AND s precomputed attack masks against the unexploded enemy bitboards, we compress a massive, multi-row O N piece-explosion per state into a fast O 1 lookup. SELECT FROM expanded states m -- Filter out the rows where the King is left under attack WHERE NOT EXISTS SELECT 1 FROM attacks precomputed ap WHERE ap.square = m.king sq AND -- If we conceptually place a Knight on the King's square, does it hit an enemy Knight? m.enemy knights bb & ap.knight mask < 0 OR -- ... does it hit an enemy pawn? ... etc m.enemy pawns bb & ap.pawn mask < 0 -- A similar subquery checks sliding pieces through mobility precomputed Board Evaluation Once the engine reaches its maximum search depth, it has to stop looking ahead and simply judge the resulting position. This “static evaluation” provides the heuristic score that tells the engine whether the sequence of moves that led to the current board was brilliant or disastrous. To do this, Quack-Mate utilises material values the static value assigned to each piece type and Tomasz Michniewski’s Simplified Evaluation Function https://www.chessprogramming.org/Simplified Evaluation Function , a famous set of Piece-Square Tables PST designed to give an engine basic positional understanding like centralising knights and castling the king without requiring complex heuristic logic. A simple sum of all material and PST values positive for white, negative for black determines the total board value. The Imperative Approach Historically, static evaluation functions looped over an array representation of the board, summing up the material and PST values corresponding to the piece at each square. 🛠️ Click to expand technical details An imperative bitboard engine provides a massive constant-factor speedup by using hardware popcount instructions which instantly count how many bits are set to 1 to sum up material, and pop lsb loops to apply Piece-Square Table PST bonuses for positional placement. Modern world-champion engines like Stockfish, however, go infinitely further: they evaluate complex heuristics like pawn structures and king safety, and increasingly rely on efficiently updatable neural networks NNUE to score the board state holistically. // A classic bitboard evaluation function int score = 0; // Hardware popcount calculates material sums instantly without looping score += popcount board.white queens 5; score -= popcount board.black queens 5; score += popcount board.white knights 2; score -= popcount board.black knights 2; // ... repeat for all piece types // piece-square tables are tabulated by popping bits U64 white knights = board.white knights; while white knights { int sq = pop lsb &white knights ; score += PST KNIGHT sq ; } // ... repeat popping loop for black knights, queens, etc. // Modern engines eschew all of this for NNUE inferences: // return evaluate nnue board ; return score; The Relational Approach Quack-Mate’s evaluation is purely mathematical and set-based, leveraging DuckDB’s ability to process massive board sets simultaneously. 🛠️ Click to expand technical details Integrating a neural network or complex pawn-structure algorithms into a single recursive SQL query is practically impossible without crushing performance. Therefore, Quack-Mate’s evaluation remains set-based the classic Material + PST approach . The SQL engine accomplishes this via a correlated subquery: for each board row, it pivots the 12 bitboard columns into 12 distinct rows on the fly using a VALUES table. The engine then performs a single set-based JOIN of this massive intermediate set against the pre-computed Piece-Square Table, summing up both the material weight and positional bonuses. SELECT id, -- We pivot the columns into rows, and join against the Piece-Square Table -- to sum up material and positional bonuses in one go COALESCE SELECT SUM pst.value FROM pst values pst, VALUES 5, wQ bb , -5, bQ bb , -- Queens 2, wN bb , -2, bN bb -- Knights etc... AS pb piece, bitboard WHERE pst.piece = pb.piece AND is bit set pb.bitboard, pst.square , 0 AS static eval FROM search tree WHERE depth = MAX DEPTH Database Notes - Referencing outer query columns like wQ bb directly inside a VALUES table constructor is historically restricted in many SQL dialects unless explicitly wrapped in a LATERAL join. DuckDB’s parser natively supports this correlated variable injection, saving us from writing a much clunkier subquery. - While this is a beautiful example of purely relational set-math, executing it dynamically at every single leaf node is computationally heavy. Quack-Mate optimises this by calculating the PST incrementally . As a piece moves down the search tree, we calculate the delta subtracting the piece’s value on the source square and adding its value on the destination square . The static evaluation at the leaf node simply reads this continuously updated, running total. The Elegance of the Single Query: Recursive Minimax The Bubble-Up: A single recursive query that expands the tree, scores the leaves, and ripples the best results back to the root. The engine has now generated the tree of legal moves and statically evaluated the final resulting board states the leaf nodes . To make a decision, these scores must bubble back up to the root node so the engine can select the optimal move, assuming best play from both sides. In an imperative language, a minimax function recursively calls itself. In SQL, we can express this entire cycle of generation, evaluation, and score propagation within a single query using a WITH RECURSIVE Common Table Expression CTE . The Imperative Approach In an imperative language, a minimax function calls itself recursively to explore the game tree. Every level in this tree represents a “ply” a single half-move by either White or Black . At each ply, the algorithm swaps sides and assumes that the player whose turn it is will play perfectly to maximise their own advantage. When the search hits the maximum depth limit, it evaluates the board and recursively bubbles those static scores back up. int minimax Board node, int depth, bool is white turn { // EVALUATION base case if depth == MAX DEPTH { return static eval node ; } // EXPANSION MoveList children = generate moves node ; // BACKPROPAGATION & The "Mini-Max" Logic int best score = is white turn ? -INFINITY : INFINITY; for Move child : children { // Recursively visit children int score = minimax child.board, depth + 1, is white turn ; if is white turn { best score = max best score, score ; // White maximises } else { best score = min best score, score ; // Black minimises } } return best score; } The Relational Approach We can represent this exact same algorithm relationally. Instead of a sequential call stack, we execute a single, elegant SQL query that preserves the exact same two-phase structure as the C++ code: a top-down expansion of the search space, followed by a bottom-up backpropagation of the minimax scores. By mapping the recursive expansion to one CTE and the bottom-up minimax aggregation to another leveraging GROUP BY parent id and side-specific MIN / MAX aggregates , the database engine handles the entire tree traversal natively in a single, set-based transaction. WITH RECURSIVE -- Expansion CTE: Top-down search tree AS -- Expansion base case: Root Node SELECT id, state, 0 as depth, is white turn FROM root UNION ALL -- Expansion step SELECT child.id, child.state, parent.depth + 1, child.is white turn FROM search tree parent JOIN possible moves child ON ... WHERE parent.depth < MAX DEPTH , -- Minimax CTE: Bottom-up minimax AS -- Back-propagation base case: -- Target depth or terminal nodes SELECT id, parent id, depth, static eval state as score, 0 as step FROM search tree s WHERE s.depth = MAX DEPTH OR NOT EXISTS SELECT 1 FROM search tree child WHERE child.parent id = s.id UNION ALL -- Back-propagation step SELECT parent.id, parent.parent id, parent.depth, CASE WHEN parent.is white turn THEN MAX child.score -- White maximises ELSE MIN child.score -- Black minimises END as score, prev.step + 1 as step FROM SELECT DISTINCT step FROM minimax prev JOIN search tree parent ON parent.depth = MAX DEPTH - prev.step + 1 JOIN recurring.minimax child ON child.parent id = parent.id GROUP BY parent.id, parent.parent id, parent.depth, parent.is white turn, prev.step SELECT score FROM minimax WHERE depth = 0; 💡 Have you noticed the recurring.minimax syntax? recurring.minimax Under standard ANSI SQL recursive CTE rules, the recursive member only has access to the rows produced in the immediately preceding step known as semi-naive evaluation . In game trees, this creates a major mixed-depth synchronization hurdle: if some lines terminate early such as checkmate at depth 2 while other lines run to depth 4 , standard ANSI CTEs evaluate the parent nodes partially across disjoint steps, leading to duplicated and corrupted minimax scores at the root. However, starting with DuckDB = 1.5 , a new feature has been introduced https://github.com/duckdb/duckdb/pull/20707 to solve this thanks to Denis Hirn for the tip : by referencing the recursive table as recurring.