Static Exchange Evaluation (SEE)

SEE simulates a capture sequence on a square to calculate the net material gain/loss. It asks: “If I make this move and we trade pieces on this square, will I gain at least v centipawns?”

/// Position::see_ge (Static Exchange Evaluation Greater or Equal) tests if the
/// SEE value of move is greater or equal to the given value. We'll use an
/// algorithm similar to alpha-beta pruning with a null window.

bool Position::see_ge(Move m, Value v) const {

  assert(is_ok(m));

  // Castling moves are implemented as king capturing the rook so cannot be
  // handled correctly. Simply assume the SEE value is VALUE_ZERO that is always
  // correct unless in the rare case the rook ends up under attack.
  if (type_of(m) == CASTLING)
      return VALUE_ZERO >= v;

  Square from = from_sq(m), to = to_sq(m);
  PieceType nextVictim = type_of(piece_on(from));
  Color stm = ~color_of(piece_on(from)); // First consider opponent's move
  Value balance; // Values of the pieces taken by us minus opponent's ones
  Bitboard occupied, stmAttackers;

  if (type_of(m) == ENPASSANT)
  {
      occupied = SquareBB[to - pawn_push(~stm)]; // Remove the captured pawn
      balance = PieceValue[MG][PAWN];
  }
  else
  {
      balance = PieceValue[MG][piece_on(to)];
      occupied = 0;
  }

  if (balance < v)
      return false;

  if (nextVictim == KING)
      return true;

  balance -= PieceValue[MG][nextVictim];

  if (balance >= v)
      return true;

  bool relativeStm = true; // True if the opponent is to move
  occupied ^= pieces() ^ from ^ to;

  // Find all attackers to the destination square, with the moving piece removed,
  // but possibly an X-ray attacker added behind it.
  Bitboard attackers = attackers_to(to, occupied) & occupied;

  while (true)
  {
      stmAttackers = attackers & pieces(stm);

      // Don't allow pinned pieces to attack pieces except the king as long all
      // pinners are on their original square.
      if (!(st->pinnersForKing[stm] & ~occupied))
          stmAttackers &= ~st->blockersForKing[stm];

      if (!stmAttackers)
          return relativeStm;

      // Locate and remove the next least valuable attacker
      nextVictim = min_attacker<PAWN>(byTypeBB, to, stmAttackers, occupied, attackers);

      if (nextVictim == KING)
          return relativeStm == bool(attackers & pieces(~stm));

      balance += relativeStm ?  PieceValue[MG][nextVictim]
                             : -PieceValue[MG][nextVictim];

      relativeStm = !relativeStm;

      if (relativeStm == (balance >= v))
          return relativeStm;

      stm = ~stm;
  }
}

The Algorithm

1. Setup Phase:

assert(is_ok(m));
  • Debug check: Verify the move is legal/valid before proceeding.
  // Castling moves are implemented as king capturing the rook so cannot be
  // handled correctly. Simply assume the SEE value is VALUE_ZERO that is always
  // correct unless in the rare case the rook ends up under attack.
if (type_of(m) == CASTLING)
    return VALUE_ZERO >= v;

Castling special case: Internally, castling is coded as “king captures own rook,” which would confuse the SEE algorithm. Just assume SEE = 0 for castling moves (safe assumption since you’re not actually capturing anything).

Square from = from_sq(m), to = to_sq(m);
PieceType nextVictim = type_of(piece_on(from));
Color stm = ~color_of(piece_on(from)); // Opponent moves next
  • We assume Move m has already happened
  • Track which piece we’re moving and where
  • The opponent responds first (they decide whether to recapture)
  • nextVictim refers to the piece that’s about to be captured next in the exchange sequence.
Value balance; // Values of the pieces taken by us minus opponent's ones
Bitboard occupied, stmAttackers;

Declare variables:

  • balance: Running total of material gained/lost
  • occupied: Bitboard tracking which squares have pieces
  • stmAttackers: Bitboard of attackers belonging to the side to move
  if (type_of(m) == ENPASSANT)
  {
      occupied = SquareBB[to - pawn_push(~stm)]; // Remove the captured pawn
      balance = PieceValue[MG][PAWN];
  }
  • En passant special case: En passant captures are weird because the captured pawn isn’t on the destination square.
  • occupied is the bitboard marking the actual square of the pawn to be captured
  • balance is the value PSQ value of pawn captured
  else
  {
      balance = PieceValue[MG][piece_on(to)];
      occupied = 0;
  }
  • Normal capture: Start balance with the value of whatever piece was on the destination square.
  • No special occupied handling: For normal moves, we don’t need to mark any special squares (the en passant case set this earlier).
if (balance < v)
    return false;

Early cutoff #1: If even after capturing their piece, the balance is still less than our threshold v, we can’t possibly reach it (since we’re about to lose our piece next). Fail immediately.

if (nextVictim == KING)
    return true;

King capture is always good: If we’re capturing with our king, we can’t lose the king in the exchange (kings can’t be captured). So it’s always at least break-even. Of course, it assumes the caller has already validated the king won’t end up in check after capturing.

balance -= PieceValue[MG][nextVictim];

Assume opponent recaptures: Subtract the value of OUR piece (the one we just moved). This is the material we’ll lose if the opponent recaptures.

Example: We captured a Rook (500) with a Knight (300)

  • balance = 500 (captured rook)
  • balance = 500 - 300 = 200 (after opponent takes our knight)
if (balance >= v)
    return true;

Early cutoff #2: If even after losing our piece, we’re still at or above the threshold v, we can return true immediately. No need to simulate further trades.

bool relativeStm = true; // True if the opponent is to move

Track whose perspective: This boolean tracks whether we’re adding (opponent captures) or subtracting (we capture) from balance. Starts true because opponent moves first.

occupied ^= pieces() ^ from ^ to;

Update occupied squares:

  • pieces(): All pieces on the board
  • ^ from: Remove piece from origin square
  • ^ to: Remove piece from destination square (if there was one)
  • ^ occupied: Handle the en passant pawn removal (if applicable)

Result: Bitboard showing all pieces after our move has been made.

// Find all attackers to the destination square, with the moving piece removed,
  // but possibly an X-ray attacker added behind it.
Bitboard attackers = attackers_to(to, occupied) & occupied;

Find all attackers: Get all pieces (from both sides) that can attack square to, considering the updated board position. X-ray attacks (bishops/rooks/queens revealed when the moving piece left) are automatically included.

Bitwise & with occupied is required here because we’re passing a modified occupied bitboard to attackers_to(), but the pieces() bitboards inside attackers_to() still refer to the original board position. They use occupied as reference only for blockers.

This will end up revealing the x-ray attacks because we have moved our piece on from and removed their piece on to.

2. Main exchange loop

Simulate the capture sequence. Each iteration represents one recapture.

  while (true)
  {
      stmAttackers = attackers & pieces(stm);

Filter to current side’s attackers: From all attackers, get only the ones belonging to the side that’s about to move.

     p// Don't allow pinned pieces to attack pieces except the king as long all
      // pinners are on their original square.
    if (!(st->pinnersForKing[stm] & ~occupied))
        stmAttackers &= ~st->blockersForKing[stm];

Handle pinned pieces: If all pinning pieces are still on their original squares (haven’t moved), then pinned pieces can’t participate in the exchange (except when capturing the king, which would be illegal anyway). Remove them from stmAttackers.

It just means if opponent hasn’t moved any of their pieces which pins our king, then none of our pieces blocking the attack to king can be considered for recaptures.

    if (!stmAttackers)
        return relativeStm;

No recapture available: If the current side has no attackers, the exchange ends. Return based on whose turn it was:

  • If opponent’s turn (relativeStm = true): They can’t recapture, so we keep the material. Return true.
  • If our turn (relativeStm = false): We can’t recapture, so they keep the material. Return false.
    // Locate and remove the next least valuable attacker
    nextVictim = min_attacker<PAWN>(byTypeBB, to, stmAttackers, occupied, attackers);

Find cheapest attacker: Use the least valuable piece to recapture (pawn < knight < bishop < rook < queen < king). This minimizes the potential loss. Also updates occupied and attackers bitboards to reflect this piece moving.

    if (nextVictim == KING)
        return relativeStm == bool(attackers & pieces(~stm));

King recapture check: If the only way to recapture is with the king, check if the opponent has any attackers left:

  • If opponent can attack back: King would be captured → illegal → exchange ends → return based on relativeStm
  • If opponent can’t attack: King safely captures → exchange continues
    balance += relativeStm ?  PieceValue[MG][nextVictim]
                            : -PieceValue[MG][nextVictim];

Update balance:

  • If relativeStm = true (opponent’s turn): Opponent captures, we LOSE material → ADD their piece value (makes balance worse for us)
  • If relativeStm = false (our turn): We capture, we GAIN material → SUBTRACT their piece value (makes balance better for us)

(This seems backwards, but remember: balance is “our gains minus our losses”, so when opponent gains, our balance gets worse)

    relativeStm = !relativeStm;

Switch turns: Toggle between opponent’s turn and our turn.

if (relativeStm == (balance >= v))
        return relativeStm;

Alpha-beta pruning cutoff:

SEE is not trying to compute an exact score.

It answers a question like:

“If I play this capture sequence, can I end up at least as good as value v?”

So the function is typically called as:

see_ge(move, v)

Meaning:

return true if SEE(move) ≥ v

balance

This is the current material balance during the simulated exchange.

  • Positive = good for side to move
  • Negative = bad for side to move

It gets updated as pieces capture each other.

relativeStm

This is a boolean meaning:

relativeStm = true   opponent is to move
relativeStm = false  side to move is to move

So it flips each ply during the exchange simulation.

Think:

  • First capture: us
  • Reply capture: them
  • Next capture: us

(balance >= v)

This is the evaluation test:

Have we reached the threshold?

if (relativeStm == (balance >= v))
    return relativeStm;

It is checking whether the current player has already “won” or “lost” the threshold battle.

Case 1: Opponent to move (relativeStm = true)

Then condition becomes:

if (true == (balance >= v))

So if balance is already ≥ v even though opponent is about to move, then:

  • We (the original side) are already safe
  • Opponent cannot avoid us reaching threshold
  • So: return true.

Case 2: Side to move (relativeStm = false)

Condition becomes:

if (false == (balance >= v))

Which means:

balance < v

So if balance is below threshold when it is our turn, then:

  • We cannot force it back above v
  • The exchange fails the threshold
  • return false;

SEE cutoffs are asymmetric. When it is our turn and the balance is already below the threshold, we cannot recover because any further capture requires sacrificing another piece, decreasing balance further. However, when it is the opponent’s turn, a low balance does not guarantee failure, because the opponent may be forced to recapture with an expensive piece, swinging the balance back upward. Therefore pruning is only safe on the maximizing side’s turn.

        stm = ~stm;
      }
    }

Switch side to move: Flip to the other color for the next iteration of the loop.