Files
NowChessSystems/docs/superpowers/specs/2026-03-29-en-passant-design.md
T
LQ63 02b75e8fac docs: add en passant design spec for NCS-9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 14:48:09 +02:00

5.0 KiB

En Passant — Design Spec

Date: 2026-03-29 Ticket: NCS-9 Status: Approved


Overview

Implement en passant capture for the NowChessSystems chess engine. The data structures (MoveType.EnPassant, GameState.enPassantTarget) and FEN serialization are already in place. This spec covers the three missing pieces: target derivation, move generation, and board mutation.


Architecture

New component: EnPassantCalculator

File: modules/core/src/main/scala/de/nowchess/chess/logic/EnPassantCalculator.scala

Mirrors CastlingRightsCalculator in structure. Three methods:

object EnPassantCalculator:

  /** Returns the en passant target square if the last move was a double pawn push.
   *  e.g. last move e2→e4 yields target e3. */
  def enPassantTarget(board: Board, history: GameHistory): Option[Square]

  /** True if moving from→to is a legal en passant capture. */
  def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean

  /** Given the destination square and the moving color, returns the square
   *  of the pawn to remove from the board. */
  def capturedPawnSquare(to: Square, color: Color): Square

enPassantTarget logic: inspect the last HistoryMove in GameHistory; if the piece now at to is a pawn that moved exactly 2 ranks, the target is the square at (to.file, midRank) between from and to.

isEnPassant logic: moving piece is a pawn, to equals enPassantTarget(board, history), and the move is diagonal (file changes by 1).

capturedPawnSquare logic: same file as to, one rank toward the moving pawn (White captures to rank 6 → captured pawn on rank 5; Black captures to rank 3 → captured pawn on rank 4).


Change: MoveValidator.pawnTargets

Add an overload accepting GameHistory:

private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square]

This calls the existing geometry-only pawnTargets(board, from, color) and appends the en passant diagonal target when applicable:

val epTarget = EnPassantCalculator.enPassantTarget(board, history)
val epCapture: Set[Square] =
  epTarget.filter: target =>
    val fi  = from.file.ordinal
    val ri  = from.rank.ordinal
    val dir = if color == Color.White then 1 else -1
    squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target)
  .toSet
existing ++ epCapture

The context-aware legalTargets(board, history, from) calls this overload for pawns.


Change: GameController.processMove

After legality is confirmed, detect en passant and apply the correct board mutation:

val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)

val newBoard =
  if isCastling then board.withCastle(turn, side)
  else
    val (b, _) = board.withMove(from, to)
    if isEP then b.removed(EnPassantCalculator.capturedPawnSquare(to, turn))
    else b

board.removed(sq) already exists on Board. History entry is unchanged — en passant is recorded as addMove(from, to) with no castle side.


Data Flow

User input: "e5d6"
  → Parser.parseMove → (e5, d6)
  → MoveValidator.isLegal(board, history, e5, d6)
      → pawnTargets(board, history, e5, White)
          → enPassantTarget(board, history) → Some(d6)
          → d6 is diagonal from e5 → included in targets
      → d6 ∈ legalTargets → legal
  → EnPassantCalculator.isEnPassant(board, history, e5, d6) → true
  → board.withMove(e5, d6) → pawn moves to d6
  → board.removed(d5)      → captured pawn removed
  → history.addMove(e5, d6)
  → GameRules.gameStatus(newBoard, newHistory, Black)

Error Handling

No new error cases. En passant is only reachable via legalTargets — if enPassantTarget is None or the pawn is not adjacent, the square is simply not included in legal moves. Illegal en passant attempts are rejected by the existing legality check before processMove applies any mutation.


Testing

EnPassantCalculatorTest (unit)

  • enPassantTarget returns None for non-pawn last moves, non-double-push, and empty history
  • enPassantTarget returns correct square after White double push (e2→e4 → target e3) and Black (e7→e5 → target e6)
  • isEnPassant returns true for a valid en passant capture, false for normal captures, wrong piece, no ep target
  • capturedPawnSquare returns correct square for White and Black

MoveValidatorTest (unit)

  • En passant target square appears in legal pawn targets after opponent double push
  • En passant target square does not appear without a preceding double push
  • En passant target disappears after a non-capturing move (history no longer shows double push)

GameRulesTest (integration)

  • Full scenario: double push → en passant capture → captured pawn absent from board
  • En passant capture that exposes own king is correctly filtered as illegal

Coverage

100% line / branch / method via scoverage, verified with jacoco-reporter/scoverage_coverage_gaps.py.