Finding the X4
I’ve been trying to build the habit of reading more. I started with a printed book, The Hobbit, dropped it after a while, then tried reading on my phone instead, except reading on a phone means the next app over is always some social media app, and I know exactly how that story ends. So I started looking for an actual e-reader.
That’s how I found the Xteink X4. Not from a review, but from a Reddit post where someone had hacked a Game Boy emulator onto one. Read books and hack on the hardware, that sold me immediately.
Deciding to build
After I ordered it, I had to wait for it to arrive, first to Miami and then to Honduras, so I had a lot of time to think about what I could actually do with it. I decided to make a chess game. I love the game, and I’ve built other chess related tools before (I’ll talk about Boardcaster later this year). At first I thought I could just load an existing engine onto the X4, but after some research I found out it’s a bare ESP32-C3 with a lot of limitations, so I decided to write everything myself, board, engine, and UI. I started reading up on how chess engines actually work, mostly on chessprogramming.org. Part of the pull, too, was the constraints themselves. I wanted to actually feel the limits of memory and low level C development, not just read about them.
The board
I started with the engine itself, since I knew that would be the hardest part. The first thing they teach you about chess engines is that you need a board representation. I made mine a single array of 64 bytes, one per square, with an enum for the pieces, then started writing move generation. I had to make sure the engine could generate all legal moves, and then make sure it evaluated them correctly. The harder part was handling special moves like castling and en passant, there are a surprising number of edge cases to cover. I even got to use a bitfield in C for the first time, to store castling rights, a nice little optimization. Here’s what the state looks like:
typedef struct {
uint8_t board[64];
uint8_t turn;
int8_t en_passant_square;
// DAMN, first time using bitfields in C, 1 int vs 4 bools
uint8_t castling_rights; // bit 0: white kingside, bit 1: white queenside, bit
// 2: black kingside, bit 3: black queenside
uint8_t halfmove_clock; /* half-moves since last pawn move or capture; draw at 100 */
uint64_t zobrist_hash;
uint8_t king_sq[2]; /* [COLOR_WHITE] and [COLOR_BLACK] */
} ChessState;
The king_sq array is a small optimization to avoid searching the whole
board for the king every time I need it. zobrist_hash is a hash of the
board state that lets me quickly check if I’ve seen a position before,
useful for detecting draws by repetition and for skipping re-evaluation of
positions I’ve already scored.
Watching it play itself
The very first version of that struct didn’t have the Zobrist hash, the king square array, castling rights, or en passant tracking. Even so, I got the engine playing games against itself, and predictably it got stuck looping the same handful of moves back and forth. I stepped in and played against it myself, beat it easily, but I was happy it could get through a full game at all. Adding the missing pieces later fixed it for good. Now when I let it play itself, it actually plays the game out, no more circles, just a real checkmate at the end.
=== Depth3 vs Depth3 (white d3, black d3) ===
1. White b1c3 nodes=1273 1ms
1. Black b8c6 nodes=2126 0ms
2. White g1f3 nodes=2750 1ms
2. Black d7d5 nodes=3749 1ms
3. White d2d4 nodes=3263 1ms
3. Black e7e6 nodes=12215 2ms
4. White e2e4 nodes=6760 1ms
4. Black f8b4 nodes=9105 2ms
5. White f1d3 nodes=16102 4ms
5. Black d5e4 nodes=14694 4ms
6. White d3e4 nodes=15993 5ms
6. Black g8e7 nodes=52327 11ms
7. White e1g1 nodes=26110 7ms
7. Black e8g8 nodes=44004 8ms
8. White d1d3 nodes=25258 5ms
8. Black h7h6 nodes=41535 9ms
9. White e4h7+ nodes=27908 5ms
9. Black g8h8 nodes=5560 1ms
10. White c1d2 nodes=28649 5ms
10. Black f7f5 nodes=32424 5ms
11. White d4d5 nodes=49847 9ms
11. Black e6d5 nodes=32980 5ms
12. White f3d4 nodes=64038 10ms
12. Black h8h7 nodes=110081 18ms
13. White d4c6 nodes=21757 3ms
13. Black b7c6 nodes=4564 1ms
14. White a1d1 nodes=12144 1ms
14. Black d5d4 nodes=8996 1ms
15. White d2e3 nodes=24275 4ms
15. Black c6c5 nodes=59447 9ms
16. White a2a3 nodes=49879 8ms
16. Black b4c3 nodes=8385 1ms
17. White b2c3 nodes=8742 2ms
17. Black e7d5 nodes=5078 1ms
18. White c3d4 nodes=54036 9ms
18. Black c5d4 nodes=16125 2ms
19. White e3d4 nodes=14975 2ms
19. Black c8e6 nodes=26055 4ms
20. White d3a6 nodes=13975 2ms
20. Black d8c8 nodes=59239 10ms
21. White a6c6 nodes=12881 1ms
21. Black c8e8 nodes=13692 3ms
22. White c6b7 nodes=12746 1ms
22. Black e8a4 nodes=20925 4ms
23. White b7b2 nodes=211518 35ms
23. Black a8b8 nodes=11104 2ms
24. White b2c1 nodes=10212 2ms
24. Black f5f4 nodes=28716 4ms
25. White f1e1 nodes=17438 3ms
25. Black f8e8 nodes=27467 5ms
26. White c2c4 nodes=55536 9ms
26. Black e6g4 nodes=23093 3ms
27. White c4d5 nodes=7793 2ms
27. Black e8e1+ nodes=14527 2ms
28. White d1e1 nodes=1259 0ms
28. Black a4d4 nodes=19893 3ms
29. White h2h3 nodes=5975 1ms
29. Black g4f5 nodes=13754 2ms
30. White c1c7 nodes=26248 3ms
30. Black b8b2 nodes=27728 4ms
31. White e1f1 nodes=56927 10ms
31. Black f5d3 nodes=20834 3ms
32. White f1d1 nodes=10189 2ms
32. Black d4f2+ nodes=18864 1ms
33. White g1h1 nodes=244 1ms
33. Black f2g2+ nodes=2757 0ms
Result: Checkmate after 66 plies (1688743 total nodes)
a b c d e f g h
8 . . . . . . . .
7 ♙ . ♛ . . . ♙ ♔
6 . . . . . . . ♙
5 . . . ♟ . . . .
4 . . . . . ♙ . .
3 ♟ . . ♗ . . . ♟
2 . ♖ . . . . ♕ .
1 . . . ♜ . . . ♚
a b c d e f g h
Starting with letters
I had a lot of fun with the screen. The first job was just making sure the pieces were readable at all, so I started with the simplest thing possible, the same letters each piece gets in FEN notation.
a b c d e f g h
8 r n b q k b n r
7 p p p p p p p p
6 . . . . . . . .
5 . . . . . . . .
4 . . . . . . . .
3 . . . . . . . .
2 P P P P P P P P
1 R N B Q K B N R
a b c d e f g h
Learning e-ink the hard way
I hadn’t written any kind of sleep or refresh cycle yet, so one night I left a game running and went to bed. When I woke up, the board was burned into the screen. I tried refreshing it, but it was too late, I can still see it faintly when the screen goes white, though it’s not noticeable once a board is actually drawn on top. That’s how I learned e-ink needs to be refreshed regularly to avoid ghosting, and that the panel only has so many refreshes in it before it starts to degrade. Now I do a full refresh every 20 plies, and the device sleeps after 10 minutes of inactivity.
Getting real piece art
After a few games on the actual display, letters weren’t cutting it, I wanted real piece art. I settled on 48x48 pixel bitmaps, 288 bytes each. Originally I had just one bitmap per piece, but I wanted more precision in the drawings, so this is where I let Claude drive for a bit: I asked it to generate bitmaps from the Cburnett chess piece set, and it did. I still had to hand-adjust a few of them, but after a couple more iterations I had a full set I was happy with, and the pieces finally looked like real pieces.
Playing it for real
Finally, I got to play a real game against the engine on the device itself. It’s genuinely weird at first, mainly because the X4 has no touchscreen, just four front buttons, a power button, and up/down buttons on the side. So before I could play anything I had to design a way to select a piece and then a square to move it to.
One of the smaller algorithms I needed was generating the list of squares a piece could reach. For sliding pieces (rooks, bishops, queens) I wrote that as a “raycast”: keep stepping in a direction until you hit the edge of the board or another piece. That ended up shaping the controls too, left and right pick a direction after selecting a piece, then up and down pick how far along that direction to move, for the sliders. Knights and pawns are simpler, they only get the direction options, no distance to scroll through.
A real opponent
Once the controls felt smooth, I was happy to find out the engine was
actually a real challenge. I lost the first game and won the second, and
I haven’t played it since, I don’t want to end up with a loss on the
record. While writing this post I asked Claude if there was a quick way to
estimate its Elo. It suggested cutechess-cli, a command line tool for
running engine matches, but I haven’t set that up yet. So instead it wrote
a small Python script that ran the engine directly against Stockfish (the
Homebrew build).
After 256 games (64 per level) against Stockfish at 300ms per move, Skeleton scored between 35% and 81% depending on the opponent’s strength, crossing even odds somewhere around Stockfish@1900-2100, for an estimated Elo of about 1860.
┌─────────────────┬────────────────┬───────┬─────────────┐
│ Stockfish level │ Record (W-D-L) │ Score │ Implied Elo │
├─────────────────┼────────────────┼───────┼─────────────┤
│ 1500 │ 49-6-9 │ 81.3% │ ~1755 │
├─────────────────┼────────────────┼───────┼─────────────┤
│ 1700 │ 32-12-20 │ 59.4% │ ~1766 │
├─────────────────┼────────────────┼───────┼─────────────┤
│ 1900 │ 27-15-22 │ 53.9% │ ~1927 │
├─────────────────┼────────────────┼───────┼─────────────┤
│ 2100 │ 18-9-37 │ 35.2% │ ~1994 │
└─────────────────┴────────────────┴───────┴─────────────┘
Where it landed
I still use the X4 for reading, but it mostly lives on my desk now so I can flash the game onto it and play a round for fun. It’s become a great conversation piece. More than anything it was just a fun project, a programming exercise that pushed me into things I’d never touched before. I’d never done hardware programming before this, and along the way I picked up a lot about C, chess engines, and e-ink displays.
What’s next
I’d absolutely do another project like this, I like chess adjacent code. I have another one already in progress called Boardcaster, a PGN file editor and viewer that exports video of games with a TTS voiceover reading out the move comments, rendering the board, arrows, and highlights as it goes. It’s basically a chess content creation tool. Once I get it out publicly I’d love to write about building it, and everything that went wrong along the way.
I’m a full stack developer by trade, so getting back into C after college was its own kind of fun, and I learned a lot about low level programming, memory management, and optimization. I also went into this as a personal “can I still code without Claude” challenge. I think I passed, but I also came out of it with a different answer: Claude is a genuinely useful tool for learning and understanding things faster, and that’s a good thing. I’ll definitely keep using it going forward.