Piece-square tables and tapered evaluation
Stage 9 · tag stage-09-pst · code in crates/engine/src/eval.rs
Until now cb2026 counted material and nothing else. All quiet moves looked equally good to it, so it played whichever one the move generator listed first: in chapter 7 it kept its queen safe with Kf1, and from the starting position it played Na3. This chapter teaches it where its pieces belong.
Piece-square tables
A piece-square table (PST) gives a piece type a bonus or penalty on each of the 64 squares. The idea is old and cheap: the evaluation adds one table lookup per piece. What goes into the tables is basic chess knowledge:
- Knights belong in the centre. On e5 a knight reaches eight squares, on a1 only two (“a knight on the rim is dim”).
- Bishops like long, open diagonals and dislike corners.
- Rooks like the seventh rank, where they attack pawns and hem in the king.
- Pawns gain value as they advance, the centre pawns most. Unmoved d- and e-pawns are penalised, since they block the bishops.
- The king hides behind its pawns in the corner while there are pieces around to attack it.
cb2026’s tables are written like a printed board, from White’s side with rank 8 at the top, so they can be read at a glance. Here is the knight’s, in centipawns:
-50 -35 -25 -20 -20 -25 -35 -50 8
-35 -15 0 5 5 0 -15 -35 7
-25 5 15 20 20 15 5 -25 6
-20 5 20 25 25 20 5 -20 5
-20 0 15 25 25 15 0 -20 4
-25 5 10 15 15 10 5 -25 3
-35 -15 0 5 5 0 -15 -35 2
-50 -30 -25 -20 -20 -25 -30 -50 1
a b c d e f g h
A black piece uses the same table, flipped: Black’s back rank is its rank 1. The flip is one XOR on the square number:
pub fn pst_index(color: Color, square: Square) -> usize {
match color {
Color::White => square.index() ^ 56,
Color::Black => square.index(),
}
}
The numbers are cb2026’s own, set by hand from the principles above. They don’t need to be exact. Stage 16 tunes all of them automatically on hundreds of thousands of positions from self-play games.
Tapered evaluation
The king’s table shows the problem with a single set of values. In the middlegame the king must hide, but in an endgame it is a strong piece that should march to the centre. Pawns, too, matter much more as the board empties and promotion gets closer.
So every value comes in two versions, middlegame and endgame, and the evaluation blends them by the game phase: a count of the pieces left on the board.
phase = knights + bishops + 2 × rooks + 4 × queens 24 at the start
score = (mg × phase + eg × (24 − phase)) / 24
At the start the score is all middlegame. With only kings and pawns left it is all endgame. After one rook pair is traded it is 20/24 middlegame. The blend changes a little with each trade, so the score never jumps, and the search has no reason to seek or avoid a trade just to cross a threshold.
/// The evaluation's tunable numbers, and switches for its term groups.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EvalParams {
/// Middlegame value of each piece type in centipawns, indexed by [`PieceType::index`]
/// (the king's value is never used: both sides always have one).
pub mg_values: [i32; 6],
/// Endgame value of each piece type.
pub eg_values: [i32; 6],
/// Middlegame piece-square tables, indexed by [`PieceType::index`].
pub mg_pst: [Pst; 6],
/// Endgame piece-square tables.
pub eg_pst: [Pst; 6],
/// How much each piece type counts toward the game phase.
pub phase_weights: [i32; 6],
/// Use the piece-square tables (off: material only).
pub use_pst: bool,
/// Blend middlegame and endgame values by phase (off: middlegame values only).
pub tapered: bool,
/// The positional terms of the full evaluation (stage 15).
pub terms: TermParams,
}
/// Reports every term of the evaluation, for both sides, to `sink`.
pub fn collect<S: TermSink>(pos: &Position, params: &EvalParams, sink: &mut S) {
for color in Color::ALL {
for piece_type in PieceType::ALL {
for square in pos.pieces(color, piece_type) {
sink.term(color, Param::Material(piece_type), 1);
if params.use_pst {
sink.term(color, Param::Pst(piece_type, pst_index(color, square)), 1);
}
}
}
}
hce::collect(pos, ¶ms.terms, sink);
}
/// The game phase: from 0 (bare kings and pawns) to [`MAX_PHASE`] (all the pieces).
pub fn phase(pos: &Position, params: &EvalParams) -> i32 {
let phase: i32 = PieceType::ALL
.iter()
.map(|&pt| params.phase_weights[pt.index()] * pos.pieces_of_type(pt).count() as i32)
.sum();
phase.min(MAX_PHASE)
}
/// Adds up the terms' values into an [`EvalTrace`].
struct TraceSink<'a> {
params: &'a EvalParams,
trace: EvalTrace,
}
impl TermSink for TraceSink<'_> {
fn term(&mut self, side: Color, param: Param, count: i32) {
let t = &mut self.trace;
let slot = match param {
Param::Material(_) => &mut t.material,
Param::Pst(..) => &mut t.pst,
Param::Mobility(_) => &mut t.mobility,
Param::Doubled | Param::Isolated => &mut t.pawns,
Param::Passed(_) => &mut t.passed,
Param::BishopPair | Param::RookOpenFile | Param::RookSemiOpenFile => &mut t.pieces,
Param::PawnShield => &mut t.king_safety,
Param::Tempo => &mut t.tempo,
};
let (mg, eg) = self.params.value(param);
slot[side.index()].0 += mg * count;
slot[side.index()].1 += eg * count;
}
fn king_danger(&mut self, side: Color, penalty: i32) {
self.trace.king_safety[side.index()].0 -= penalty;
}
}
/// Works out the evaluation's parts; the total is from White's point of view.
pub fn trace(pos: &Position, params: &EvalParams) -> EvalTrace {
let mut sink = TraceSink {
params,
trace: EvalTrace::default(),
};
collect(pos, params, &mut sink);
let mut t = sink.trace;
t.phase = phase(pos, params);
let (mg, eg) = t.net();
t.total = if params.tapered {
(mg * t.phase + eg * (MAX_PHASE - t.phase)) / MAX_PHASE
} else {
mg
};
t
}
/// The position's value in centipawns, from the side to move's point of view.
pub fn evaluate(pos: &Position, params: &EvalParams) -> i32 {
let total = trace(pos, params).total;
match pos.side_to_move() {
Color::White => total,
Color::Black => -total,
}
}
The piece values are tapered too: pawns and rooks are worth more in the endgame.
In the shell
eval shows the evaluation term by term. Here’s the Italian Game with White’s king walked
to e2 instead of castled:
> fen r1bq1rk1/pppp1ppp/2n2n2/2b1p3/2B1P3/2N2N2/PPPPKPPP/R1BQ3R w - - 0 1
> eval
Evaluation, from White's side, in pawns:
Middle End
Material +0.00 +0.00
Piece-squares -0.55 +0.35
Sum -0.55 +0.35
Phase 24/24: 100% middlegame, 0% endgame
Total: -0.55 for White, so -0.55 for White to move
The king on e2 costs White about half a pawn in the middlegame and would be a plus in an endgame. With all the pieces on the board, the middlegame figure counts.
set pst off and set tapered off switch the two ideas off separately, to measure each.
From the starting position cb2026 now develops its knights instead of playing Na3:
info depth 7 seldepth 18 score cp 25 nodes 644665 nps 6024906 time 107 hashfull 47 pv b1c3 b8c6 g1f3 g8f6 d2d4 d7d5 c1e3
Pitfalls
- Asymmetry bugs. A mistake in flipping tables for Black makes the evaluation favour one
colour. The test
mirrored_positions_score_the_same_for_the_side_to_moveevaluates every bench position and its colour-flipped mirror and requires the same score. - Too much positional weight. Big table values let the engine give away material for “good squares”. Tables stay small next to piece values until tuning says otherwise.
- Phase jumps. Switching tables at a fixed amount of material, instead of blending, makes the evaluation jump when one piece is traded, and the search plays for that jump.
What it’s worth
| Test | Result |
|---|---|
| Stage 9 vs stage 8, 8+0.08, SPRT [0, 5] | +14 −0 =0 over 14 games: passed |
The test stopped after 14 straight wins, too few games for a meaningful Elo figure. A material-only engine that doesn’t know where its pieces belong has no chance against one that does.
Further reading
- Chess Programming Wiki: Piece-Square Tables, Tapered Eval, Game Phase, Simplified Evaluation Function (a well-known hand-written set of tables, by Tomasz Michniewski).