Pawn Race
Why
My goal is to write full-stack apps with a deep understanding of what's going on under the hood. Because I need to know how to scale, debug, and optimize. To practice, I wanted a small toy project to learn a new language, new tools, and how to build a back-end.
Idea
I love chess. I'm very bad at it, but I do love the game. The rules are simple, but the possibilities for creativity are enormous. I'm also a big fan of the Attack on Titan show, which has many moments that remind me of a chess game. No spoilers — but there's an intense and suspenseful scene that is very similar to pawn promotion. I found it intereseting and I decided to build a mini game around it: reach the end of the board and become a queen — the most powerful piece in chess.
Tools
For the back-end, I chose Go (Golang). I'd wanted to learn it for a long time because of its simplicity and performance. My two favorite aspects of the language are its concurrency model and error handling:
- Concurrency: Goroutines and channels make it easy to write concurrent, non-blocking code. Concurrent code means automatically parallelizable code. And the cool thing about goroutines is that they're cheap.
- Error handling: Go forces explicit error handling, making code more predictable and robust, because every point where something can go wrong is addressed. I've started applying this pattern to my JavaScript code. If you miss JS error handling style, you can use
panic
andrecover
— similar totry...catch
in JavaScript.
For the front-end, I picked Next.js. I've worked with it for years, it gets the job done, and I see no reason to switch right now.
How
Back-end
I started with a single-player mode. This required a chess engine that could search for the opponent's moves. I implemented the Alpha-Beta pruning algorithm with a transposition table to store position evaluations and avoid redundant searches.
Before building the engine, I needed core chess logic. I decided to store and transfer the current chess position between the front-end and back-end using FEN notation. I created a `Position` struct in Go to represent a position and implemented the logic to:
- Find all valid moves
- Make and validate a move
- Convert between
Position
and FEN
Once the engine was done, I set up a web server using Gin with two endpoints:
POST /move/opponent
: opponent makes a movePOST /move/user
: user makes a move, followed by the opponent's move
I added a rate-limiter and set server read/write timeouts as a starting point for DDoS protection.
For logging, I used slog for structured logging, zerolog as a slog handler, and lumberjack to manage log file rotation. I created a protected logs endpoint (token-based).
For critical errors, I built a Telegram bot to receive server notifications - free and fast to set up.
My favorite part
This was a toy project, so I needed the server to be cheap but reasonably fast.
I learned about concurrency and parallelism in Go and implemented a worker pool using goroutines and channels. Workers pull jobs from a queue — useful even on a single CPU, as heavy tasks could be split into smaller batches.
This is exactly what I did for move searching. I broke the work into batches to prevent blocking users with a slow request. I also added a timeout: if searching took too long, the engine would return the best move found so far instead of wasting resources.
I used Go's profiling tools to measure performance and memory usage. I generated 100 unique FEN positions and used k6
to simulate 100 virtual users sending requests for 90 seconds. Before testing, I set the CPU governor to performance mode to ensure maximum frequency.
The CPU profile revealed excessive allocation/deallocation of short-lived objects and heavy map access. The search and chess logic functions didn't even make the top 10 most expensive calls.
Optimizations I made:
- Replaced maps with arrays where possible
- Implemented a Zobrist hasher for faster transposition table lookups
- Reused objects instead of constantly recreating them (thread-safe, since they weren't position-specific)
Results:
runtime.memclrNoHeapPointers
: from 18.29s to 580msruntime.mapaccess1
: completely removed from top 10 calls- Peak RAM usage: ~6MB
- Added safeguards to prevent unbounded growth of transposition tables
- No memory leaks detected (so far)