8.5 KiB
Pawn Promotion Design — NCS-10
Date: 2026-03-29
Issue: NCS-10 — Implement Pawn Promotion
Modules: modules/api (domain types), modules/core (logic, game loop)
Overview
Pawn promotion is a two-step interaction: when a pawn reaches the opponent's back rank, the game pauses and prompts the player to choose a promotion piece (Queen, Rook, Bishop, or Knight). The move is not complete until a piece is selected. The choice is recorded in game history so promotions survive FEN/PGN serialization and round-trips.
Requirements (from DoD)
- Promotion is mandatory — move is not completed until piece is chosen
- All four promotion targets are selectable (Q, R, B, N)
- Underpromotion (e.g. to knight) works correctly
- PGN notation records the promotion piece (e.g. e8=Q)
- Tests cover: promotion to each piece, promotion via capture, underpromotion
Architecture
1. History Recording
File: modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala
Extend the Move type to record promotion choices:
case class Move(
from: Square,
to: Square,
castleSide: Option[CastleSide],
promotionPiece: Option[PromotionPiece] = None
)
promotionPiece = Nonefor non-promotion movespromotionPiece = Some(Queen|Rook|Bishop|Knight)for promotion movesaddMove()overloaded to accept promotion piece:addMove(from, to, castleSide?, promotionPiece?)
2. Move Validation
File: modules/core/src/main/scala/de/nowchess/chess/logic/MoveValidator.scala
Add promotion detection:
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
board.pieceAt(from) match
case Some(Piece(_, PieceType.Pawn)) =>
val destRank = to.rank
(from.rank == Rank.R7 && destRank == Rank.R8) || // White pawn to R8
(from.rank == Rank.R2 && destRank == Rank.R1) // Black pawn to R1
case _ => false
This identifies when a move is pawn reaching the back rank. The move is legal (passes isLegal()), but incomplete until a promotion piece is chosen.
3. Game Loop Flow
File: modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
New MoveResult variant:
case class PromotionRequired(
from: Square,
to: Square,
newBoard: Board,
newHistory: GameHistory,
captured: Option[Piece],
newTurn: Color
) extends MoveResult
Flow in processMove():
- Validate move is legal (existing logic)
- Detect castling or promotion:
- If castling → apply transformation, return
Moved/MovedInCheck - If promotion → return
PromotionRequired(move board state pre-promotion, pawn still on source square) - Otherwise → apply move, return
Moved/MovedInCheck
- If castling → apply transformation, return
New function: Complete promotion
def completePromotion(
board: Board,
history: GameHistory,
from: Square,
to: Square,
piece: PromotionPiece,
turn: Color
): MoveResult
This applies the pawn move, places the promoted piece, and returns Moved or MovedInCheck.
Loop integration:
def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
// ... existing render + prompt
processMove(board, history, turn, input) match
case MoveResult.PromotionRequired(from, to, newBoard, newHistory, captured, newTurn) =>
println("Promote to: (q/r/b/n)? ")
val pieceInput = StdIn.readLine().trim.toLowerCase
val piece = pieceInput match
case "q" => Some(PromotionPiece.Queen)
case "r" => Some(PromotionPiece.Rook)
case "b" => Some(PromotionPiece.Bishop)
case "n" => Some(PromotionPiece.Knight)
case _ => None
piece match
case None =>
println("Invalid piece. Choose (q/r/b/n).")
gameLoop(board, history, turn) // retry promotion choice
case Some(p) =>
// completePromotion returns a MoveResult (Moved or MovedInCheck)
// and is handled recursively through the same loop
completePromotion(newBoard, newHistory, from, to, p, turn, newTurn) match
case result: MoveResult.Moved =>
// handle as normal move
gameLoop(result.newBoard, result.newHistory, result.newTurn)
case result: MoveResult.MovedInCheck =>
// handle check state
gameLoop(result.newBoard, result.newHistory, result.newTurn)
case _ =>
// should not happen
gameLoop(board, history, turn)
case other => // existing cases (Quit, InvalidFormat, NoPiece, WrongColor, IllegalMove, Moved, MovedInCheck, Checkmate, Stalemate)
4. PGN Support
File: modules/core/src/main/scala/de/nowchess/chess/notation/PgnExporter.scala
When exporting a move that includes promotion:
def moveToSan(move: HistoryMove): String =
val base = s"${move.from}${move.to}"
move.promotionPiece match
case Some(piece) => s"$base=${piece.label.head.toUpperCase}"
case None => base
Output: e7e8=Q, e7e8=n (underpromotion to knight), etc.
File: modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala
Parse promotion notation during PGN import:
def parsePromotion(move: String): Option[PromotionPiece] =
// Extract '=Q' suffix and convert to PromotionPiece
5. Test Coverage
Files:
modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scalamodules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
Test scenarios (using FEN to set up board positions):
-
Promotion detection:
- White pawn on e7 moving to e8 →
isPromotionMovereturnstrue - Black pawn on e2 moving to e1 →
isPromotionMovereturnstrue - Pawn moving but not to back rank →
isPromotionMovereturnsfalse
- White pawn on e7 moving to e8 →
-
Each piece type:
- Promote to Queen: e7e8 + "q" → pawn becomes Queen
- Promote to Rook: e7e8 + "r" → pawn becomes Rook
- Promote to Bishop: e7e8 + "b" → pawn becomes Bishop
- Promote to Knight: e7e8 + "n" → pawn becomes Knight
-
Capture + promotion:
- Pawn captures enemy piece while promoting (e7d8 capturing bishop + promote to Queen)
-
Underpromotion:
- Promote to Knight instead of Queen (strategic underpromotion)
-
Both colors:
- White pawn (R7 → R8)
- Black pawn (R2 → R1)
-
Rejection cases:
- Pawn blocked on back rank (no move completes)
- Illegal capture during promotion
-
History recording:
- Move with promotion records
promotionPiecefield - Move without promotion has
promotionPiece = None
- Move with promotion records
-
Game flow:
processMove()returnsPromotionRequiredcompletePromotion()advances game state correctly- Game status (check, mate, draw) evaluated after promotion completes
Data Flow Diagram
User input: "e7e8"
↓
processMove() → parseMove() → (Square(E, R7), Square(E, R8))
↓
Validate legality → MoveValidator.isLegal(board, history, from, to)
↓
Detect promotion? → MoveValidator.isPromotionMove(board, from, to)
↓
Yes → return PromotionRequired(from, to, board, history, ...)
↓
gameLoop handles result, prompts: "Promote to: (q/r/b/n)?"
↓
User input: "q"
↓
completePromotion(board, history, from, to, Queen, turn)
↓
Apply pawn move, place Queen, record in history with promotionPiece=Queen
↓
Evaluate game status, continue loop
Implementation Notes
- Promotion is not a choice in
processMove()— the function only detects and pauses. The loop handles the interaction. - The board state in
PromotionRequiredis unchanged — pawn still on source square untilcompletePromotion()applies the move. - Castling remains independent — no interaction between promotion and castling logic.
- Coverage goals: 100% line, branch, and method for all new code (per CLAUDE.md).
- Naming: Rename
de.nowchess.chess.logic.MovetoHistoryMoveto avoid collision withde.nowchess.api.move.Move(feedback from prior work).
Scope
- Core: move validation, history recording, game loop interaction
- API: types already exist (
PromotionPiece,MoveType.Promotion) - Notation: PGN export/import support (deferred if integration tests pass without it)
- Rendering: no UI changes beyond console prompts
Risks
- Off-by-one errors on rank detection: White R7→R8, Black R2→R1. Tests must verify both.
- Game status evaluation: Must evaluate check/mate/stalemate after promotion completes, not before.
- Backward compatibility: Extending
GameHistory.Moverequires migration of existing saves (none yet; not a blocker).