When you build a chess engine, you stand on the shoulders of giants. Piece values were tuned decades ago. King safety, pawn structure, mobility, Stockfish has canonical implementations of all of them. You can crack open any modern engine and read a decades,long conversation about what makes a chess position good.
When you build an engine for a game you invented six months ago, none of that exists.
I've been building Larss, a 1v1 tetromino placement game that feels like Block Blast but plays like chess. Two players alternate placing pieces on a shared 11×11 to 14×14 board, filling rows, columns, and diagonals to clear cells and score. Matches take 5-10 minutes and are ~99% skill, the same piece sequence goes to both players, so wins emerge from position, not luck.
Then came the engine. Rust, iterative deepening, alpha-beta pruning, the search side was familiar territory. The hard part was the evaluation function: given a position, how good is it? This is a story about the multi-layer perceptron I built to answer that question, why I couldn't just use NNUE, and what I learned when a much simpler model beat it.
The problem: no priors, no theory, no dataset
In chess, if I tell you the black queen is under attack and the white king is exposed, you don't need a computer to know who's winning. Larss has no such shared intuition. What does "development" mean when your pieces are randomized tetrominoes? What's the equivalent of a backward pawn?
I had a few obvious features:
- Score difference.
- Number of legal placements per player (mobility).
- Cell safety: for every empty cell,
(% of line completed) × (% of that line your color). High values mean you're one placement away from letting your opponent close a line and score.
But I didn't know how to combine them. A weighted sum? A polynomial? Something learned? And even if I picked a functional form, I had no ground truth. There were no grandmaster games to imitate. There wasn't a single Larss game on the internet that wasn't one I'd played myself.
So the plan became: pick an architecture flexible enough to learn the combinations, and bootstrap the training data from self-play.
Why not NNUE
The obvious question: why not NNUE? Efficiently Updatable Neural Networks are the current champion in computer chess. Small networks, incrementally updated as moves are played, absurdly fast to evaluate. Stockfish switched to NNUE and gained hundreds of Elo. If it worked for chess, why not here?
Two reasons.
Fixed input dimensions. Classic NNUE encodings — HalfKP, HalfKA, are designed around an 8×8 board and chess's piece set. Larss boards range from 11×11 to 14×14, and I want the engine to play all of them. I'd either need one network per size (four to train, maintain, and version) or a padding scheme that wastes capacity and muddies training.
Incremental updates depend on locality. NNUE's speed comes from only recomputing the neurons affected by a move. In chess, one move changes a small, bounded number of input features. In Larss, a single tetromino placement can clear an entire row, column, or diagonal, dozens of cells flip at once, plus every empty cell's safety score potentially shifts. The bookkeeping wins less.
I could have forced NNUE to work. But I'd be paying its complexity cost without collecting its speed benefit. I went a different way.
Bitmask popcount architecture
Larss already runs on bitboards. Each cell state (empty, player 1, player 2, blocked) is packed across a handful of wide integers. Bitboards let me evaluate any line — row, column, or diagonal — with a couple of instructions: mask the line, popcount to get occupied cells, popcount an AND with the player mask to get that player's cells.
The insight for the evaluator was: don't feed the network raw board state. Feed it bitmask-derived features that are already dimensionless with respect to board size.
Concretely, each position produces a fixed-length feature vector:
struct Features {
score_diff: f32,
mobility_ratio: f32,
// Bucketed by line length, because a 14x14 board
// has longer diagonals than an 11x11.
filled_fraction: [f32; 3],
own_cell_fraction: [f32; 3],
// Histogram: how many empty cells sit in each danger bucket?
danger_histogram: [f32; 8],
// ... a handful more
}
Every feature is either a normalized ratio or a bucketed count. An 11×11 board and a 14×14 board produce vectors with the same shape and comparable magnitudes. The MLP behind the two hidden layers, 128 and 64 units, ReLU, never touches the raw board.
Evaluation pipeline: bitboards -> popcount into features -> forward pass -> scalar. About 40k parameters total. Fast enough to sit inside alpha-beta at reasonable depths.
Training with texel tuning
I still needed data. I bootstrapped it the way chess engines have for years: texel tuning.
The idea, borrowed from the Texel engine, is to treat the evaluator as a probabilistic predictor of game outcomes. Given a position and its final result (win, loss, draw), the evaluation should approximate the log-odds of winning. Squash the scalar output through a sigmoid, compare it to the actual result, backpropagate.
The pipeline:
- Self-play a bunch of games with a weak baseline evaluator (initially just score difference).
- For every position along every game, record
(features, final_outcome). - Train the MLP to minimize
MSE(sigmoid(k * eval), outcome), wherekis a scaling constant that's also learned. - Replace the evaluator with the trained one. Self-play again.
- Repeat.
A few passes in, the network was playing "positional" moves I hadn't hand-coded, leaving lines partially completed to bait the opponent, avoiding placements that handed the opponent easy safety improvements. It felt like it had actually learned something about Larss.
Results, and the lesson I didn't want to learn
The MLP worked. It played real Larss.
Then I trained a linear model on the same features, the same data, the same texel loss. Just a dot product between the feature vector and a weight vector.
It was better. Not enormously, a few tens of Elo. But it was better, and it was roughly a hundred times faster to evaluate, which meant deeper search inside the same time budget, which meant it was actually much better in practice.
Sitting with that for a while, the reason became obvious. My features were already the hard part. Cell safety is a nonlinearity, I computed filled × own_fraction before the model ever saw it. Mobility ratios are nonlinear in the underlying counts. The danger histogram already discretized a continuous risk signal into interpretable bins. The MLP was mostly relearning identities on top of features that already encoded the interactions worth encoding. Two hidden layers of hardware to recover a linear combination I could have written by hand.
The lessons I'm keeping:
- Where you put the nonlinearity matters more than how much of it you have. Feature engineering and neural depth are substitutes, not complements. Good features leave less work for the network to do — sometimes nothing.
- Interpretability is a real feature. With linear weights, I can read the evaluator. I know that safety costs about 0.7 per danger unit and mobility is worth about 0.4 per legal move. When it plays a bad move, I can debug it. When I want to tune style, I can adjust one weight.
- NNUE didn't fit, and forcing it would have been worse than not using it. The right architecture for Larss wasn't a smaller version of the right architecture for chess. That was hard to accept given how much the chess engine community has invested in NNUE, but architecture follows problem structure, not fashion.
- Speed inside search dominates almost everything. A linear evaluator that lets me search two plies deeper wins against a smarter one that doesn't.
The engine that ships with Larss today is the linear model. The MLP lives on a branch, quietly, as a reminder.
Play a game
If any of this sounded fun, you can play at larss.io. It's free, browser-based, matches take about five minutes, and I'd love to know whether the strategy feels as chess-like on the receiving end as it does when I'm tuning weights against it. Board sizes from 11×11 to 14×14. See you there.
Top comments (0)