A full hand-crafted evaluation
Stage 15 · tag stage-15-hce · code in crates/engine/src/hce.rs and eval.rs
The search is now deep and selective. What it finds at the leaves is still judged by chapter 9’s evaluation: material and piece-square tables. Those know that a knight likes the centre, but nothing about how the pieces relate to each other. A bishop on its best square counts the same whether its diagonals are open or blocked by its own pawns. A king behind three pawns counts the same as one whose shelter has been torn away. This chapter adds the classic positional terms of a hand-crafted evaluation (HCE).
The terms
/// The positional terms' numbers (each `(middlegame, endgame)` in centipawns) and switches.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TermParams {
/// Mobility: value per safe square, by piece type (pawn and king unused).
pub mobility: [MgEg; 6],
/// Mobility is counted relative to this typical number of safe squares, by piece type.
pub mobility_typical: [i32; 6],
/// Each pawn beyond the first on a file.
pub doubled: MgEg,
/// Each pawn with no friendly pawn on a neighbouring file.
pub isolated: MgEg,
/// A passed pawn, by rank from its own side (index 1 = second rank ... 6 = seventh).
pub passed: [MgEg; 8],
/// Two bishops or more.
pub bishop_pair: MgEg,
/// A rook on a file with no pawns.
pub rook_open_file: MgEg,
/// A rook on a file with no pawns of its own side.
pub rook_semi_open_file: MgEg,
/// Each friendly pawn in the 3×2 block in front of the king (at most 3 count).
pub pawn_shield: MgEg,
/// King danger units per attacked square around the king, by attacking piece type.
pub king_attack_weights: [i32; 6],
/// King danger penalty (middlegame) = units² × this / 100...
pub king_danger_scale: i32,
/// ...up to this much.
pub king_danger_max: i32,
/// For the side to move.
pub tempo: MgEg,
/// Score mobility.
pub use_mobility: bool,
/// Score pawn structure and passed pawns.
pub use_pawns: bool,
/// Score the bishop pair and rook files.
pub use_pieces: bool,
/// Score king safety.
pub use_king_safety: bool,
/// Score the tempo.
pub use_tempo: bool,
}
impl Default for TermParams {
/// The hand-set values (stage 15). The engine uses the tuned ones (see [`crate::tuned`]).
fn default() -> TermParams {
TermParams {
mobility: [(0, 0), (4, 4), (5, 5), (2, 4), (1, 2), (0, 0)],
mobility_typical: [0, 4, 6, 7, 13, 0],
doubled: (-10, -20),
isolated: (-10, -15),
passed: [
(0, 0),
(0, 5),
(5, 10),
(10, 20),
(20, 35),
(35, 60),
(60, 90),
(0, 0),
],
bishop_pair: (30, 50),
rook_open_file: (25, 10),
rook_semi_open_file: (12, 8),
pawn_shield: (15, 0),
king_attack_weights: [0, 2, 2, 3, 5, 0],
king_danger_scale: 35,
king_danger_max: 500,
tempo: (10, 5),
use_mobility: true,
use_pawns: true,
use_pieces: true,
use_king_safety: true,
use_tempo: true,
}
}
}
impl TermParams {
/// All positional terms switched off.
pub fn none() -> TermParams {
TermParams {
use_mobility: false,
use_pawns: false,
use_pieces: false,
use_king_safety: false,
use_tempo: false,
..TermParams::default()
}
}
}
Mobility
A piece is worth more when it has more places to go. For each knight, bishop, rook and queen, cb2026 counts the squares it attacks that are neither occupied by its own side nor covered by an enemy pawn: squares it could actually use. Each piece type scores per square above or below a typical count. A bishop with 11 safe squares beats one with 3, and that’s how the evaluation learns about “good” and “bad” bishops without a rule for each.
Pawn structure
- Doubled pawns, two on one file, can’t protect each other, and the rear one is often blocked.
- Isolated pawns, with no friendly pawn on either neighbouring file, can only be defended by pieces.
- Passed pawns have no enemy pawn ahead on their own or a neighbouring file, so nothing but pieces can stop them. Their bonus climbs steeply with each rank, and more steeply in the endgame, where few pieces remain to stop them.
Pieces
- The bishop pair: two bishops cover both square colours and work well together in open positions, worth about a third of a pawn more in the middlegame and half a pawn in the endgame.
- Rooks on open files (no pawns) or semi-open files (no pawns of their own) can reach the enemy position.
King safety
In the middlegame the king needs shelter, and the danger it faces depends on how many enemy pieces are bearing down on it.
- The pawn shield scores each friendly pawn in the 3×2 block in front of the king.
- King attacks count, for each enemy piece, how many squares of the king zone (the king’s square and its neighbours) it attacks, weighted by piece type: knight and bishop 2, rook 3, queen 5. The penalty is the square of the total, scaled and capped. One piece alone rarely mates, but several together often do, and the quadratic shape captures that.
King safety only has middlegame values. As pieces come off, the phase blend fades it out by itself.
Tempo
The side to move gets a small bonus: having the move is worth something. The evaluation is still symmetric, since mirroring a position (swapping colours and flipping the board) also swaps the side to move.
/// Reports every switched-on positional term, for both sides, to `sink`: which parameter
/// applies and how many times (see [`TermSink`]).
pub fn collect<S: TermSink>(pos: &Position, p: &TermParams, sink: &mut S) {
let occupied = pos.occupied();
for us in Color::ALL {
let them = !us;
let our_pawns = pos.pieces(us, PieceType::Pawn);
let their_pawns = pos.pieces(them, PieceType::Pawn);
if p.use_mobility {
// Safe squares: not our own pieces, not covered by an enemy pawn.
let area = !pos.pieces_of_color(us) & !pawn_attack_span(pos, them);
for pt in PIECES {
for square in pos.pieces(us, pt) {
let count = (piece_attacks(pt, square, occupied) & area).count() as i32;
sink.term(
us,
Param::Mobility(pt),
count - p.mobility_typical[pt.index()],
);
}
}
}
if p.use_pawns {
for square in our_pawns {
let file = Bitboard::file(square.file());
let neighbours = adjacent_files(square.file());
// Doubled: another of our pawns further up the same file.
if !(our_pawns & file & ranks_ahead(us, square.rank())).is_empty() {
sink.term(us, Param::Doubled, 1);
}
if (our_pawns & neighbours).is_empty() {
sink.term(us, Param::Isolated, 1);
}
// Passed: no enemy pawn ahead on this file or a neighbouring one.
let ahead = (file | neighbours) & ranks_ahead(us, square.rank());
if (their_pawns & ahead).is_empty() {
sink.term(us, Param::Passed(relative_rank(us, square)), 1);
}
}
}
if p.use_pieces {
if pos.pieces(us, PieceType::Bishop).count() >= 2 {
sink.term(us, Param::BishopPair, 1);
}
for square in pos.pieces(us, PieceType::Rook) {
let file = Bitboard::file(square.file());
if (file & (our_pawns | their_pawns)).is_empty() {
sink.term(us, Param::RookOpenFile, 1);
} else if (file & our_pawns).is_empty() {
sink.term(us, Param::RookSemiOpenFile, 1);
}
}
}
if p.use_king_safety {
let king = pos.king_square(us);
// Pawn shield: our pawns on the king's file and its neighbours, one or two ranks
// ahead of it.
let in_front = ranks_ahead(us, king.rank()) & !ranks_ahead(us, two_ahead(us, king));
let shield = our_pawns & (Bitboard::file(king.file()) | adjacent_files(king.file()));
let shield_count = (shield & in_front).count().min(3) as i32;
sink.term(us, Param::PawnShield, shield_count);
// Attacks on the king zone, weighted by attacker; the penalty grows quadratically.
let zone = king_attacks(king).with(king);
let mut units = 0;
for pt in PIECES {
for square in pos.pieces(them, pt) {
let hits = (piece_attacks(pt, square, occupied) & zone).count() as i32;
units += hits * p.king_attack_weights[pt.index()];
}
}
let danger = (units * units * p.king_danger_scale / 100).min(p.king_danger_max);
sink.king_danger(us, danger);
}
}
if p.use_tempo {
sink.term(pos.side_to_move(), Param::Tempo, 1);
}
}
The trace
Every term is recorded for each side separately, so the shell’s eval command can show
exactly where a score comes from. Here is the Italian Game again, with White’s king walked to
e2:
> fen r1bq1rk1/pppp1ppp/2n2n2/2b1p3/2B1P3/2N2N2/PPPPKPPP/R1BQ3R w - - 0 1
> eval
Evaluation in pawns, each side from its own point of view:
White Black Net
Middle End Middle End (W-B)
Material +39.40 +42.00 +39.40 +42.00 +0.00
Piece-squares +0.20 -0.15 +0.75 -0.50 -0.55
Mobility -0.61 -0.91 -0.61 -0.96 +0.00
Pawn structure +0.00 +0.00 +0.00 +0.00 +0.00
Passed pawns +0.00 +0.00 +0.00 +0.00 +0.00
Pieces +0.30 +0.50 +0.30 +0.50 +0.00
King safety +0.10 +0.00 +0.44 +0.00 -0.34
Tempo +0.10 +0.05 +0.00 +0.00 +0.10
Phase 24/24: 100% middlegame, 0% endgame
Total: -0.79 for White, so -0.79 for White to move
Black’s castled king has three shield pawns. White’s king has one shield pawn (e4) and is worse placed on the piece-square table. That costs White most of a pawn, which is about what a strong player would say. Mobility is negative for both sides only because it is measured against the typical counts of a more open position. Only the difference between the sides matters.
set mobility off, set pawns off, set pieces off, set kingsafety off and set tempo off
switch the groups off one at a time.
Getting it right
An evaluation bug doesn’t crash anything; it just makes the engine play worse. Two kinds of test catch most of them:
- Symmetry. Every bench position and its mirror image must get the same score for the side to move. A term that forgets to flip a rank for Black fails this at once.
- Hand-checked positions. Small positions where each term’s value can be worked out by hand: doubled and isolated pawns, a passed pawn on the seventh, the bishop pair with a rook on an open file, a sheltered king against an exposed one.
Pitfalls
- Double counting. The piece-square tables already reward advanced pawns and central knights. The passed-pawn and mobility bonuses add to that, so the values have to be set with both in mind. Stage 16’s tuning sets them together.
- Speed. Mobility and king attacks generate attacks for every piece at every leaf. On the bench, the engine searches about a quarter fewer nodes per second than with material and tables alone. The extra knowledge has to win more than the depth it costs. The SPRT is the judge.
- Hand-set numbers. Every value here is a reasonable guess. The next chapter replaces guessing with fitting them to game results.
What it’s worth
| Test | Result |
|---|---|
| Stage 15 vs stage 14, 8+0.08, SPRT [0, 5] | +163 −41 =39 over 243 games: passed (about +192 ± 45 Elo) |
Further reading
- Chess Programming Wiki: Evaluation, Mobility, Pawn Structure, Passed Pawn, Bishop Pair, Rook on Open File, King Safety.