https://www.youtube.com/watch?v=Tqw_V1R7A6g
NOTE: The video is of an older version from a couple of months ago.
Hello everyone, This is an opensource release of my 100% Luau chess engine which is extremely fast. You can read the #help-and-feedback:creations-feedback post here.
If you like this project, check out my other projects also focused on high-performance & low level computing:
- https://devforum.roblox.com/t/c-c-and-rust-to-luau-reasm/3915431
- https://devforum.roblox.com/t/scriptbench-free-opensource-heavy-duty-benchmarker/3815286
This post is intended to introduce you to my project and give you a short introduction to the insane optimizations that went into this.
The license does NOT permit using this in your own projects or Roblox games without permission from myself (DM me on discord bitsplicer if you are looking for permission).
Copyright (c) 2026 AsynchronousAI on GitHub
Permission is hereby view-only: you are allowed to use this software for
personal, educational, or reference purposes, but you may NOT copy, modify,
distribute, or use it in your own projects without explicit written permission
from the copyright holder.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF, OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- Luau move-generation
src/shared/engine - Luau bot
src/server/bot - Frontend in roblox-ts. Using RhosGFX Chess Icons.
- Designed for Native Luau & Parallel Luau
- 64bit-int bitboards, with Magic Bitboards for sliding pieces
- Opening recognition
- Multithreaded transposition tables
- Search optimizations: aspiration windows, futility pruning, quiescence search, iterative deepening
- Incremental Zobrist hashing, Evaluation, and Attacks
- Encoding boards into
buffers. - FEN support.
[details]
You can see a max 10,004,581 nodes/sec with Native Luau single threaded.
luau tests/perft.test.luau --codegen
Perft depth: 1, nodes: 20, time: 0.0001s. 359550.65 nodes/sec
Perft depth: 2, nodes: 400, time: 0.0001s. 2796388.14 nodes/sec
Perft depth: 3, nodes: 8902, time: 0.0011s. 7985647.08 nodes/sec
Perft depth: 4, nodes: 197281, time: 0.0250s. 7881099.66 nodes/sec
Perft depth: 5, nodes: 4865609, time: 0.4863s. 10004581.52 nodes/sec
Perft depth: 6, nodes: 119060324, time: 13.1533s. 9051725.88 nodes/sec
luau tests/perft.test.luau
Perft depth: 1, nodes: 20, time: 0.0000s. 1095889.06 nodes/sec
Perft depth: 2, nodes: 400, time: 0.0002s. 2427920.62 nodes/sec
Perft depth: 3, nodes: 8902, time: 0.0037s. 2414456.36 nodes/sec
Perft depth: 4, nodes: 197281, time: 0.0713s. 2768833.47 nodes/sec
Perft depth: 5, nodes: 4865609, time: 1.5610s. 3116975.49 nodes/sec
Perft depth: 6, nodes: 119060324, time: 40.3179s. 2953042.36 nodes/sec
NOTE: The 618,409 NPS is still multithreaded and is including move generation, alpha beta pruning, transpositions, and so much more.
This has the bot play an entire game against itself, and the depth is the minimum amount of moves forward it checked, while maxPly is the maximum.
luau tests/minimax.test.luau --codegen
Move 1: rnbqkbnr/pppppppp/8/8/8/2N5/PPPPPPPP/R1BQKBNR b KQkq - 1 1
b1c3 | eval: -114.00 | depth: 3 | maxPly: 5 | time: 51ms | nodes: 26444 | 528094 nodes/sec
Move 2: rnbqkb1r/pppppppp/5n2/8/8/2N5/PPPPPPPP/R1BQKBNR w KQkq - 2 2
g8f6 | eval: -20.00 | depth: 4 | maxPly: 5 | time: 51ms | nodes: 26586 | 531608 nodes/sec
Move 3: rnbqkb1r/pppppppp/5n2/8/5P2/2N5/PPPPP1PP/R1BQKBNR b KQkq 21 0 2
f2f4 | eval: 10.00 | depth: 4 | maxPly: 5 | time: 51ms | nodes: 25702 | 513971 nodes/sec
...
Move 273: 4r3/8/3K4/8/2qb4/8/3B4/1k6 b - - 99 137
c7d6 | eval: 4795.00 | depth: 10 | maxPly: 0 | time: 1ms | nodes: 0 | 0 nodes/sec
Move 274: 4r3/8/3Kq3/8/3b4/8/3B4/1k6 w - - 100 138
c4e6 | eval: 4795.00 | depth: 9 | maxPly: 10 | time: 51ms | nodes: 13161 | 263146 nodes/sec
Game over to draw_50_move_rule
Complete
Maximum NPS: 618409
Average NPS: 241244
NOTE: Luau in a Roblox game is slower than CLI.
depth: 5
depthReached: 5
maxPly: 7
eval: 94
time: 152ms
nodes: 54152
[/details]
NOTE: These sections will give a small introduction to the concept, and focus more how it was ported to work in a 32bit system and Luau.
I would highly reccomend https://www.chessprogramming.org/Main_Page if you want to get a deeper introduction to these topics.
[details] Bitboards are the core of this chess engine, where rather than using a 2D matrix storing pieces (called a mailbox system), a 64bit binary is used for every type of piece to hold where they are located.
The benefits of a bitboard is being able to use binary operators (accessible with Luau bit32) to be able to compute where all pawns go in a single math operation rather than with loops, or using an OR to find out a full occupancy bitboard.
Because Luau only supports 32bit integers, one number can only represent half of the board. The only solution would be to use many integers for one bitboard.
A table could not be used to store each bitboard because of GC pressure. To solve this I used @Dekkonot's int64 (great library) which represent 64bit integers as a vector(24bit, 12bit, 24bit).
Eventually I switched to dedicated lo and hi variables, which did make the code less readable but much faster.
The benefit of dedicated variables was that reading/writing from vectors was not necessary and the expensive function calls would be eliminated (function calls cost much more that you would think!).
Native Luau can also inline binary operators directly and have bit32 functions run as fast as math operators.
[/details]
[details] Many times in chess a repeat position is being computed, in these cases a Transposition Table stores (or memoizes) the results for certain functions so expensive computations do not need to run again.
Because the engine is multithreaded we use SharedTable as the Transposition Table.
Using an entire board as a key for a table is extremely inefficent which is where Zobrist hashing comes in.
Zobrist hashing stores random 32bit integers for different boolean traits of the boards, for example:
- kingside castling
b2is awhitekinge3is an enpassant square
All of these traits then XOR with eachother giving a unique number for the board state.
Incremental hashing is when the entire search tree hashes there, rather than leaf nodes. This is beneficial because instead of reading every trait and hashing based on that at the end, it will progressively perform a single operation for every move it checks. XOR is also a reversible operator:
a XOR b = c thus c XOR b = a.
[/details]
[details] Sliding pieces are much more difficult to compute that knights for example because blockers have to be accounted for. The naive implementation would be a for loop in every direction until a piece is hit.
While this method is
O(1)compared to loops, the constant factor is still large. This is why magic bitboards get implemented later.
bit32.countlz and bit32.countrz count the leading and trailing zeros.
This does not sound like much at first but imagine we precompute rays for every possible square a rook could attack from. This would be the north ray:
0 0 0 0 1 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
If you AND this with a occupancy bitboard you get:
0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
If you now use countrz it will tell you how many zeros appear before the first one, so if in our original ray we wipeout everything but the first one in the intersection bitboard we will get a ray.
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
[/details]
[details] Magic Bitboards are when you precompute every single possible position of a sliding piece and blocker positions will have their attacks precomputed.
Similarly to transposition tables a hashing function is needed, the original implementation of Magic Bitboards use 64bit multiplication which in a 32bit language is extremely expensive!
10.42% 130,989 µs mul64_hi64 [./src/shared/engine/attacks_init.luau:13]
I used the Grant Osborne optimization to use magic numbers specifically for 32bit.
Unfortunately I could not use off-the-shelf magic numbers because those are optimized to be used in 64bit, where collisions are much more likely.
I wrote a custom magic number generator in Luau, which took extremely long. I then switched to C++ and was able to generate perfect magic numbers in a couple of seconds. [/details]
[details] Let's take a break from all of this crazy optimization.
The opening database was extracted from: https://github.com/lichess-org/chess-openings/ and with a Python script is converted to a .json file Rojo can make into a ModuleScript.
In order to search the openings database the Python script also reads the PGN and simulates the moves generating a FEN which is used as the key in the table.
Whenever the server recieves a move, it will convert the board to FEN and check the openings database for the opening name.
[details]
In this chess engine, an entire move can be stored in a single integer. That one number can include:
- to position
- from position
- piece that is moving
- capture
- en passant
- castling
This is done because storing every move as a table would be super expensive, and rather packing all these values in a single integer makes usage much cheaper.
bit32 was used to shift binary values and move them all to fit inside one integer.
popcount is a function which counts how many binary bits appear inside of a number, it can be used in a chess engine to count how many checkers the king has for example.
Usually popcount uses a loop that counts (O(n), n being bits), and sometimes with poplsb to run faster (O(c), c being enabled bits)
Here is how a O(1) implementation could be derived:
popcount(hi, lo) == 1(hi,lohas 1 bit)popcount(hi) + popcount(lo) = 1(that means both halves combined have one bit)(hi == 0 && popcount(lo) == 1) or (lo == 0 && popcount(hi) == 1)(eitherhihas one bit, orlodoes)popcount(x) == 1meansxis a power of2- can be simplified to
x ~= 0 and bit32.band(x, (x - 1) == 0)
[/details]