Files
NowChessSystems/docs/superpowers/specs/2026-03-23-chess-check-checkmate-stalemate-design.md
T

7.2 KiB

Chess Check / Checkmate / Stalemate — Design Spec

Date: 2026-03-23 Status: Approved


Scope

Implement check detection, checkmate (win condition), and stalemate (draw) on top of the existing normal-move rules. En passant, castling, and pawn promotion are out of scope for this iteration.


Architecture

New: GameRules object

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

Owns all check-aware game logic. MoveValidator retains its documented geometric-only contract ("ignoring check/pin").

GameRules
  isInCheck(board, color): Boolean
  legalMoves(board, color): Set[(Square, Square)]
  gameStatus(board, color): PositionStatus

isInCheck(board, color)

Finds the king square for color by scanning board.pieces for a Piece(color, PieceType.King). If no king is found (constructed/test boards), returns false.

Then checks whether any enemy piece's MoveValidator.legalTargets contains that square. This works correctly for all piece types, including the king: kingTargets returns the squares the king can move to, which are identical to the squares the king attacks, so using legalTargets for attack detection is correct by design.

Returns true if the king square is covered by at least one enemy piece.

legalMoves(board, color)

  1. Filter board.pieces to entries where piece.color == color.
  2. For each such (from, piece), call MoveValidator.legalTargets(board, from) to get geometric candidates.
  3. For each candidate to, apply board.withMove(from, to) to get newBoard.
  4. Keep only moves where isInCheck(newBoard, color) is false (i.e., the move does not leave own king in check).
  5. Return the full set of (from, to) pairs that survive this filter.

gameStatus(board, color)

Returns a PositionStatus enum value based on legalMoves(board, color) and isInCheck(board, color):

  • MatedlegalMoves is empty and king is in check → the side to move has been checkmated
  • DrawnlegalMoves is empty and king is not in check → stalemate (draw)
  • InChecklegalMoves is non-empty and king is in check → game continues under check
  • Normal — otherwise

Local PositionStatus enum

Defined in GameRules.scala. Names are intentionally distinct from MoveResult variants to avoid unqualified-name collisions in GameController.scala:

enum PositionStatus:
  case Normal, InCheck, Mated, Drawn

Modified: MoveResult (in GameController.scala)

Three new variants; existing variants are unchanged:

Variant When used
MovedInCheck(newBoard, captured, newTurn) Move was legal; opponent is now in check but has legal replies
Checkmate(winner: Color) Move was legal; opponent is Matedwinner is the side that just moved
Stalemate Move was legal; opponent is Drawn (no legal reply, not in check)

Moved continues to be used when gameStatus returns Normal.


Modified: GameController.processMove

After computing (newBoard, captured) from board.withMove:

  1. Call GameRules.gameStatus(newBoard, newTurn).
  2. Map to the appropriate MoveResult:
PositionStatus.Normal   → Moved(newBoard, captured, newTurn)
PositionStatus.InCheck  → MovedInCheck(newBoard, captured, newTurn)
PositionStatus.Mated    → Checkmate(turn)   // turn = the side that just moved
PositionStatus.Drawn    → Stalemate

Modified: GameController.gameLoop

New terminal branches (both print a message then restart):

  • Checkmate(winner) → print "Checkmate! {winner.label} wins.", then recurse with (Board.initial, Color.White)
  • Stalemate → print "Stalemate! The game is a draw.", then recurse with (Board.initial, Color.White)

New non-terminal branch:

  • MovedInCheck(newBoard, captured, newTurn) → print the same optional capture message as Moved (when captured.isDefined), then print "{newTurn.label} is in check!", then recurse with (newBoard, newTurn)

Restart vs. exit: Checkmate and stalemate restart the game automatically (no prompt). This is intentionally asymmetric with Quit, which exits. Quit is an explicit user request to stop; Checkmate/Stalemate are natural game endings that should roll into a new game.


Test Strategy

All tests are unit tests extending AnyFunSuite with Matchers with JUnitSuiteLike.

GameRulesTest — new file

Scenario Method Expected
King attacked by enemy rook on same rank isInCheck true
King not attacked (only own pieces nearby) isInCheck false
No king on board (constructed board) isInCheck false
Move that exposes own king to rook is excluded legalMoves does not contain that move
Move that blocks check is included legalMoves contains the blocking move
Checkmate: White Qh8, Ka6; Black Ka8 — Black king is in check (Qh8 along rank 8), cannot escape to a7 (Ka6), b7 (Ka6), or b8 (Qh8) gameStatus Mated
Stalemate: White Qb6, Kc6; Black Ka8 — Black king has no legal moves (a7/b7/b8 all controlled by Qb6), not in check gameStatus Drawn
King in check with at least one escape square gameStatus InCheck
Normal midgame position, not in check, has moves gameStatus Normal

GameControllerTest additions — new processMove cases

Scenario Expected MoveResult
Move leaves opponent in check (has escape) MovedInCheck
Move results in checkmate Checkmate(winner) where winner is the side that moved
Move results in stalemate Stalemate

GameControllerTest additions — new gameLoop cases

Scenario Expected output / behavior
gameLoop receives Checkmate(White) Prints "Checkmate! White wins." and continues (new game)
gameLoop receives Stalemate Prints "Stalemate! The game is a draw." and continues (new game)
gameLoop receives MovedInCheck with a capture Prints capture message AND check message
gameLoop receives MovedInCheck without a capture Prints check message only

Development Workflow (TDD)

  1. Create GameRules.scala with empty/stub method bodies that compile but return placeholder values (false, Set.empty, PositionStatus.Normal).
  2. Write all GameRulesTest tests — they should fail.
  3. Implement GameRules logic until GameRulesTest is green.
  4. Add new MoveResult variants to GameController.scala; update processMove to call GameRules.gameStatus (stub the match arms initially).
  5. Write new GameControllerTest cases — they should fail.
  6. Implement processMove match arms and gameLoop new branches until all tests pass.
  7. Run ./gradlew :modules:core:test — full green build required.

Files Changed

File Change
modules/core/src/main/scala/de/nowchess/chess/logic/GameRules.scala New
modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala New
modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala Add MoveResult variants; update processMove and gameLoop
modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala Add new test cases

No changes to modules/api or MoveValidator.