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.