Writing a Chess Evaluation Function: Material, Piece-Square Tables, Mobility

The last post ended on a promise: the evaluation was material-only on purpose, so the node counts measured alpha-beta and not my taste in chess, and a real evaluation was coming. This is that. Piece values, piece-square tables, a midgame-to-endgame taper, a mobility term, and the tests that caught more bugs than the code deserved.

Worth saying up front: the search was already correct. Alpha-beta returned the same move as full-width minimax at every depth and the node counter agreed with perft to the node. None of that stopped the engine playing chess that would embarrass a beginner.

Table of Contents

Correct Search, Terrible Chess

Watching the material-only engine play is funny for about four minutes. It develops a knight, then puts it back. It moves a rook a1-b1, then b1-a1. It will not castle. Leave a piece hanging and it takes it instantly, six plies deep — then goes back to shuffling.

None of that is a bug. Material returns the same number for a knight on a1 as for one on e5, so a whole subtree scores identically and alpha-beta keeps the first move that wasn't worse. Which move that is depends on my generator's emission order, not on chess.

So it wasn't playing badly. It was playing optimally against a function that had no opinion about anything but material. Every preference an engine has is either found by search or written into the evaluation. None of it appears from nowhere.

Counting Wood, Properly This Time

The values didn't change. Pawn 100, knight 320, bishop 330, rook 500, queen 900, and the king has no value at all.

The bishop sitting ten centipawns above the knight does something small and specific. Bishops keep their range as the board empties and knights don't. Ten centipawns won't make it a positional master, but it breaks bishop-for-knight trades the right way more often than not.

The king has no value because it can never be captured. Give it 20,000 and the term cancels — both sides always have exactly one — leaving an overflow risk for nothing. Mate is handled in the search, where the move list comes back empty.

static final int[] PIECE_VALUES = { 100, 320, 330, 500, 900, 0 };
//                                    P    N    B    R    Q   K

int material(Board board) {
    int score = 0;
    for (int sq = 0; sq < 64; sq++) {
        Piece p = board.pieceAt(sq);
        if (p == null) continue;
        int v = PIECE_VALUES[p.type()];
        score += (p.color() == WHITE) ? v : -v;
    }
    return score;   // positive means White is better, always
}

That loop touches 64 squares to find at most 32 pieces; the set-bit version the board representation was designed for is on the list.

The One Line That Decides Who's Winning

Note the comment on that return: positive means White. Negamax doesn't want a White-relative score. It wants the score from the point of view of whoever is to move, because the whole reason it collapses two functions into one is that -negamax(child) flips perspective on the way back up.

int evaluate(Board board) {
    int score = material(board) + pieceSquare(board) + mobility(board);
    // everything above is White-relative; negamax wants mover-relative
    return board.whiteToMove() ? score : -score;
}

I got this wrong first time and wrote about it in the minimax post, where it cost me an evening. Worth repeating because it has no symptoms. Nothing throws, the scores stay plausible, White plays sensibly, and Black plays to lose with full conviction at full depth, because at Black's nodes the search is maximising White's material.

Piece-square tables make this worse: more terms carry a sign, and one inverted term rots the whole thing quietly. Keep every term White-relative and flip once, at the exit.

Sixty-Four Numbers Per Piece

A piece-square table is 64 integers per piece type, added to the piece value depending on where the piece stands. A knight on a1 attacks two squares; on e5, eight. Search finds that eventually, but only by looking further than it can afford. So you pay once and read it back free at every leaf.

Here's my knight table, laid out the way a board is — a lightly tweaked version of the well-known simplified-evaluation tables that circulate in chess programming circles, not something I derived.

        a     b     c     d     e     f     g     h
   8   -50   -40   -30   -30   -30   -30   -40   -50
   7   -40   -20     0     0     0     0   -20   -40
   6   -30     0    10    15    15    10     0   -30
   5   -30     5    15    20    20    15     5   -30
   4   -30     0    15    20    20    15     0   -30
   3   -30     5    10    15    15    10     5   -30
   2   -40   -20     0     5     5     0   -20   -40
   1   -50   -40   -30   -30   -30   -30   -40   -50

You can read the chess straight off it. Rim bad, corners worse, centre worth twenty centipawns, and the bonus on d2 and e2 nudges the knight off b1 and g1 early. A knight on a1 is worth 270 to this engine; on e5, 340. That gap alone stopped most of the shuffling.

The pawn table is the one I fiddled with most. It rewards advancement, heavily on ranks 6 and 7, and penalises d2 and e2 so the engine pushes its central pawns. I gave f2 a small negative that c2 doesn't have: pushing the f-pawn early opens a diagonal at your own king, and the tables don't know where the king is.

Then the mirroring, where the bugs live. Tables are written from White's point of view, rank 8 at the top so they read like a board. Black uses the same table, rank flipped:

// squares are a1 = 0 ... h8 = 63; tables are stored a1-indexed after a
// one-time flip at startup, so the source layout above stays readable
static int pstValue(int type, int sq, int color, int[] table) {
    int index = (color == WHITE) ? sq : (sq ^ 56);
    return table[index];
}

sq ^ 56 flips the rank and leaves the file alone, because the board mirrors between colours across the ranks, not the files. Black's a-file is still the a-file. Write it as a file flip and it passes on every table that happens to be left-right symmetric — most of them — and fails only on the ones that aren't, like my f2 penalty. You find that with the symmetry test below.

One King Table Is Always Wrong

The king broke my one-table-per-piece assumption within a day. In the middlegame it wants to sit on g1 behind three unmoved pawns, so the table rewards g1 and c1 and penalises the centre. In an endgame that table is a disaster: mine sat on g1 while the opposing king marched to e5 and ate everything.

The fix is a tapered evaluation: two tables per piece, midgame and endgame, blended by how much material is left. Standard technique, and PeSTO is the well-known set of tuned tables built on it. I hand-wrote a cruder pair instead, to understand what tuning would later change.

static final int[] PHASE_WEIGHT = { 0, 1, 1, 2, 4, 0 };
static final int TOTAL_PHASE = 24;   // 4*1 + 4*1 + 4*2 + 2*4

int phase(Board board) {
    int p = 0;
    for (int t = KNIGHT; t <= QUEEN; t++) {
        p += PHASE_WEIGHT[t] * (board.count(WHITE, t) + board.count(BLACK, t));
    }
    return Math.min(p, TOTAL_PHASE);   // promotion can push this over 24
}

int taper(int mg, int eg, int phase) {
    return (mg * phase + eg * (TOTAL_PHASE - phase)) / TOTAL_PHASE;
}

Phase 24 is the opening, phase 0 bare kings, everything between a linear blend. Pawns weigh zero deliberately: sixteen pawns and no pieces is an endgame however crowded the board looks. The Math.min isn't decoration — promote to a second queen and the total exceeds 24, the blend weight goes above 1, and you extrapolate past values either table ever held.

These are the constants the whole evaluation runs on:

PieceValue (cp)Phase weightCounted for mobility
Pawn1000No
Knight3201Yes
Bishop3301Yes
Rook5002Yes
Queen9004Yes
King0No

Mobility is weighted at 2 centipawns per available move, and that number has a history.

The First Term That Isn't a Lookup

Material and piece-square tables are array reads. Mobility isn't. It's the first term that does real work, at every leaf, millions of times per search. I count pseudo-legal destination squares per side, skipping pawns and the king, reusing the lookups from the magic bitboard work:

int mobility(Board board, int side) {
    long own = board.occupancy(side);
    long occ = board.occupancy();
    int moves = 0;
    for (int t = KNIGHT; t <= QUEEN; t++) {
        long bb = board.bitboard(side, t);
        while (bb != 0) {
            int sq = Long.numberOfTrailingZeros(bb);
            bb &= bb - 1;
            moves += Long.bitCount(attacks(t, sq, occ) & ~own);
        }
    }
    return moves * MOBILITY_WEIGHT;
}

Pseudo-legal, not legal, on purpose: filtering for pins means a make/unmake per candidate at every leaf, which an evaluation cannot carry. A pinned rook counting its moves is a small inaccuracy; legality checking at the leaves would be a catastrophe.

It still cost me. Adding the term visibly slowed the search — nodes per second dropped, and the same fixed-time search reached shallower depths than the day before. I haven't re-run the instrumented benchmark from the last post, so I won't put a number on it.

That trade is the real question for any evaluation term. Not whether it encodes true chess knowledge, but whether the knowledge is worth more than the depth it costs. An evaluation twice as smart and half as fast can be a downgrade: the ply you gave up was seeing tactics no positional term will find.

There's a second problem, and it's the one I find genuinely awkward. Mobility double-counts with the tables: a knight on e5 already collects +20 from the table, and it also has eight destinations instead of two, so it gets paid twice for one fact. I'd need to isolate each term in self-play to know how much of the gain belongs to which. I don't have a clean explanation and would rather say so than pretend it's tidy.

How Do You Test an Opinion?

This is the part almost nobody writes about, and it took longest. An evaluation function has no correct answer. Perft works because the legal move count in a position is a published, independently verified fact. Nobody publishes a table saying this position is worth +37 centipawns. So you don't test the output. You test the properties.

Colour symmetry. This one assertion is worth more than the rest combined. Take a position, flip it vertically, swap every piece's colour, swap the side to move. That's the same position with the players exchanged, so the White-relative score must be exactly negated.

@Test
void evaluationIsColourSymmetric() {
    for (String fen : TEST_FENS) {
        Board a = Board.fromFen(fen);
        Board b = Board.fromFen(mirrorFen(fen));   // flip ranks, swap colours and stm

        assertEquals(whiteRelative(a), -whiteRelative(b), fen);
        // the negamax wrapper is mover-relative, so these must be EQUAL, not negated
        assertEquals(evaluate(a), evaluate(b), fen);
    }
}

Read those two assertions carefully, because I didn't. I wrote the second with a minus sign in front, on autopilot, and it failed everywhere. Half an hour convinced my tables were mirrored wrong, and the test was the broken thing: evaluate() is already relative to the side to move, so with the mover flipped too, the mover is in an identical situation. Same number.

Once it was right it caught three bugs: table mirroring, an asymmetric weight I'd typo'd into the bishop table, and the sign of the endgame king term. Fifteen lines including the FEN mirror helper.

Stability across make/unmake. Evaluate, play a move, unplay it, evaluate again. The numbers must be identical. Once I keep incremental state — and I will, when the naive loop becomes the bottleneck — this catches an update that doesn't undo itself perfectly.

Hand-built sanity positions. Symmetric material with symmetric structure scores near zero. The same position plus a White knight scores about +320, not +290 and not +400. These catch the coarse mistakes symmetry lets through, because a bug applied evenly to both colours is still symmetric.

Self-play, the only verdict that counts. New evaluation against old, fixed depth, a few hundred games from varied openings so they don't all follow one line. Everything above proves the function is self-consistent. None of it proves the engine got stronger.

And the number that matters is how many games. A 55% score over 100 games is nothing — the error bars swallow it whole, and I've twice been briefly delighted by noise. Hundreds minimum, and for a small tweak, more than you're willing to run.

Five Things I Got Wrong

Black's pawns wanted to march backwards. The first mirroring applied the flip to White instead of Black. White played normally. Black's pawn table rewarded rank 2 and punished rank 7, so Black mostly just refused to push anything. It took me longer than I'd like to admit to connect that with an upside-down table.

Divide by zero on bare kings. My first taper normalised by a phase total computed from the pieces actually on the board rather than the constant 24. Clever, briefly. Then the search hit K vs K at a leaf, the total was zero, and an ArithmeticException came out of the middle of a depth-6 search. Wrong for exactly one position type, which is exactly the type deep search finds.

The bishop retreat phase. I first set the mobility weight at 10 centipawns per move, which felt modest. It is not modest. The engine started playing pointless bishop retreats to long diagonals purely to maximise its move count, hanging pawns on the way. Dropping it to 2 stopped that, and I have no justification for 2 beyond exactly that.

The h-pawn thing. Early on the engine kept pushing h-pawns for no reason I could see. The cause was embarrassing: I'd typed the pawn table in from a forum post I only half read, and the row I copied rewarded flank pawns over central ones. I never read the numbers I pasted. Now every table goes in as a board and gets read as one.

The evaluation got slow enough to be a net loss. At one point I had mobility, a first pass at pawn structure and a king-safety term all running per leaf, and the search reached a full ply shallower in the same time. The knowledge didn't pay for the lost ply. Pawn structure and king safety came back out.

What Transfers and What's Just Mine

The piece values and the general shape of the tables transfer to any engine. Centre good, rim bad, advanced pawns good, castled king good in the midgame and central king good in the endgame — that's chess, not my code, which is why the same shapes turn up everywhere.

The exact weights don't. A mobility weight of 2 is tuned against my search depth, my move ordering and my current bugs. Anyone with a transposition table and a quiescence search is searching a different tree, so the right weight for them is a different number. That's why engines retune after every significant search change. And the timing observations are one laptop, one JVM, single-threaded. If you're building your own, though, piece-square tables are the highest return-per-line change available: six arrays and one XOR, and the engine stops shuffling.

What's Next

Refining the taper first, since both table sets were hand-written and the endgame one is thin. Then pawn structure — isolated, doubled and passed pawns, which need a pawn hash table to be affordable — and king safety, where most of the remaining strength hides. After that, texel tuning: fit the weights to real game results instead of guessing.

I also still owe the previous post two things. A transposition table, so the search stops re-deriving positions it has already seen. And quiescence search, so the evaluation stops being called mid-capture-sequence and reporting a number one recapture destroys. That matters more now: a better evaluation applied at a bad moment is still a bad number, arguably worse, since it's wrong with more precision.

If you've written your own tables and landed on a different shape, or my mobility weight looks wrong to you, say so in the comments. I'm still working out which terms earn their cost and which I've kept because they felt like chess.

Author's note: Everything here is from my own Java engine, bugs included. One honest gap: this post contains no nodes-per-second figures and no self-play percentages, because I changed the evaluation after the benchmark run in the previous post and haven't re-instrumented since. The two places a measurement belongs — the cost of the mobility term, and the fixed-depth self-play result for the heavy evaluation — are marked TODO in the page source and will be filled from a real run. The piece values, tables and weights above are the actual constants in my code.

Frequently Asked Questions

Why does an engine with a material-only evaluation still play badly?

Because material is flat across the board. A knight on a1 and a knight on e5 both score 320, so the engine takes whichever equal-scoring line the generator emitted first. That looks like rook shuffling, refusing to castle, and pieces that never leave the back rank. The search is fine; the function it optimises does not care where the pieces stand.

What are piece-square tables and why does a knight on e5 score more than a knight on a1?

A piece-square table is 64 integers per piece type, added to the piece value as a bonus for standing on a given square. A knight on a1 attacks two squares; a knight on e5 attacks eight. The table encodes that as a constant, so the engine gets it free at every leaf instead of searching far enough to discover it.

How do you mirror piece-square tables for Black?

Tables are written once from White's point of view, and Black indexes the same table with the square number XOR 56. That flips the rank and leaves the file alone, which is correct because chess mirrors between colours across the ranks, not the files. A file flip instead passes on any file-symmetric table and fails silently on the ones that are not.

What is a tapered evaluation and why do you need separate midgame and endgame tables?

A tapered evaluation keeps two sets of tables, midgame and endgame, and blends them by how much material is left. The king table that keeps you behind your pawns in the middlegame is the same one that keeps you on g1 in a king and pawn endgame, where the king needs to walk to the centre. The blend weight comes from remaining non-pawn material.

How do you test a chess evaluation function?

Four layers. A colour-symmetry assertion comparing each position against its rank-flipped, colour-swapped mirror, which catches mirroring bugs, sign bugs and asymmetric weights in one line. A check that the score survives a make and unmake round trip. Sanity checks on hand-built positions where equal material scores near zero. And self-play at fixed depth, the only test that says whether the engine got stronger.

Is a mobility term worth the cost in a chess evaluation function?

It is the first term that is not a table lookup, so it does real move-generation work at every leaf and it will cost you nodes per second. It also double-counts with piece-square tables, since a centralised knight already has more moves by construction. Worth having, but weight it low and confirm it with self-play, not by whether the scores look sensible.