First thoughts on Zig for crafting a chess engine
In my first semester at Mines, I wrote a chess engine in C named Kirin. During Winter Break, I took it upon myself to learn Zig by rewriting Kirin in this new, fun systems level language.
Why Zig?
After watching a few of Andrew Kelly's talks on Zig, specifically this one, The Road to Zig 1.0 - Andrew Kelly I was sold on the language and began going through the Ziglings exercises. These exercises are designed to help teach you the syntax and quirks of Zig, useful functions from the standard library (which is amazing, by the way), about memory allocators, and other language essentials. Going through these exercises won't make you a pro, but it will certainly put you in the right direction.
I like Zig because Zig is just really cool. It has developer friendly syntax (aside from it's occasional verbosity), allows you to write code that is evaluated at compile time or runtime, and is extremely fast, even faster than C... yes, really. From Zig's website, here are some of it's key features:
- Small, simple language
- No hidden control flow, memory allocations, preprocessor, or macros
- Four build modes allowing the programmer to optimize for speed or safety
- Zig is a competitor to C, rather than depending on it
The Problem with C
To show off some of Zig's features and how I'm implementing them in Kirin, we'll take a look at two example functions. The first one is written in C, and was implemented in Kirin v0.01.
U64 setOccupancy(int index, int bitsInMask, U64 attackMask){
U64 occupancy = 0ULL;
//loop over the range of bits within attack mask
for(int i = 0; i < bitsInMask; i++) {
int square = getLSBindex(attackMask);
popBit(attackMask, square);
//make sure occupancy is on board
if(index & (1 << i)) occupancy |= (1ULL << square);
}
return occupancy;
}
The problem with this C code is that you think it does one thing, but because of preprocessor macros it is difficult if not impossible to actually understand what this function does. First of all, the U64 variable type isn't a variable type in C at all, I've defined it.
#define U64 unsigned long long
What else isn't as it appears? Well, popBit isn't actually a function, but a preprocessor macro. This means rather than acting like a normal function call, anytime popBit is called, the following code is DIRECTLY inserted into the place of popBit, aka macro expansion. Also, no, it doesn't need a semicolon to end the statement.
#define popBit(bitboard, square) ((bitboard) &= ~(1ULL << (square)))
While these preprocessor macros were useful to me while writing Kirin in C, it makes it incredibly difficult to debug your code, and macro expansion can have odd side effects.
How does Zig solve this problem?
Now that preprocessor macros are gone, we have eliminated a large number of foot guns. What else does Zig do to keep us safe, not just in terms of memory but also in terms of code readability and understandability? Let's take a look at the new set occupancy function written in Zig. There's quite a bit going on, so take a second to look at the two functions and see if you can see what's happening.
pub fn setOccupancy(index: u32, bitsInMask: u6, attackMask: u64) !u64 {
var occupancy: u64 = @as(u64, 0);
var attackMaskCopy = attackMask;
for (0..bitsInMask) |count| {
const square: u6 = @intCast(try getLSBindex(attackMaskCopy));
attackMaskCopy = try popBit(&attackMaskCopy, square);
const bitShift: u6 = @intCast(count);
if ((index & (@as(u64, 1) << bitShift)) != 0) {
occupancy |= @as(u64, 1) << square;
}
}
return occupancy;
}
If you're new to Zig, you probably have a few syntax questions, so let's quickly address them. Pub simply means that this function is accessible from outside of the zig file it was written in, and the ! in front of !u64 means that this function can return an error OR an unsigned 64 bit integer.
pub fn setOccupancy(index: u32, bitsInMask: u6, attackMask: u64) !u64 {
...
In the case this function fails for some reason, I have the ability to account for that without segfaulting as it would in C. We also have this wonderful for loop syntax, that simply goes between the range of values and assigns the iterates a payload variable inside of the | |.
for (0..bitsInMask) |count| {
...
Now we get to the meat, where C took us down a misleading path. However, in Zig, this function works exactly how it looks like it does, and how it should have worked in C. I make a copy of the attack mask, get the index of the least significant bit, and then pass a pointer to the attack mask copy to popBit, where it performs a bitwise xor operation on the value at the pointer and returns the value at the pointer or an error.
var attackMaskCopy = attackMask;
for (0..bitsInMask) |count| {
const square: u6 = @intCast(try getLSBindex(attackMaskCopy));
attackMaskCopy = try popBit(&attackMaskCopy, square);
...
Here's the popBit function in Zig.
pub fn popBit(bitboard: *u64, square: u6) !u64 {
if (getBit(bitboard.*, square) != 0) {
bitboard.* ^= (@as(u64, 1) << square);
}
return bitboard.*;
}
The Zig code does explicitly what you tell it to. Nothing goes on behind the scenes that is impossible to debug or understand, and for this reason I see Zig as a major competitor to C in the very near future. While the Zig code ends up being a bit more verbose, allows me to understand the intricacies of chess programming in a deeper way and gives me the ability to actually debug and account for errors when something goes wrong. While I still have lots of work left before Kirin Zig is complete, I've genuinely enjoyed my time in Zig and I look forward to what Andrew Kelly and the Zig Foundation put together in the years to come.
Check out my progress on Kirin Zig here.