Files
NowChessSystems/docs/superpowers/specs/2026-03-31-50-move-rule-design.md
T

6.2 KiB

50-Move Rule — Design Spec

Branch: feat/NCS-11 Date: 2026-03-31


Overview

Implement the FIDE 50-move rule: when 100 consecutive half-moves (plies) have been played without a pawn move or capture, the player whose turn it is may claim a draw by typing draw. The engine notifies observers when the threshold is reached so the UI can prompt the player.


Motivation

The 50-move rule prevents games from continuing indefinitely in positions where neither side can force checkmate. Under FIDE rules it is a player-claimed draw, not automatic.


Section 1: Data Model — GameHistory

GameHistory gains one new field:

case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0)

The default value 0 means all existing construction sites compile unchanged.

Clock update rule

The clock resets to 0 on any pawn move or capture; otherwise it increments by 1.

The main addMove overload gains two optional boolean flags:

def addMove(
  from: Square,
  to: Square,
  castleSide: Option[CastleSide] = None,
  promotionPiece: Option[PromotionPiece] = None,
  wasPawnMove: Boolean = false,
  wasCapture: Boolean = false
): GameHistory =
  val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
  GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)

The base addMove(HistoryMove) overload is made private; all public call sites route through the flagged overload above.

The no-argument overload addMove(from, to) used in tests and en passant history recording defaults both flags to false (clock increments) and remains for backward compatibility.


Section 2: Clock Update in GameController

applyNormalMove

Two flags are derived from already-available data before calling history.addMove:

val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
val wasCapture  = captured.isDefined   // computed earlier in the same method
val newHistory  = history.addMove(from, to, castleOpt,
                    wasPawnMove = wasPawnMove, wasCapture = wasCapture)

En passant moves are pawn captures, so both flags are true — the clock resets.

completePromotion

Pawn promotion is always a pawn move, so wasPawnMove = true:

val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)

Section 3: Claim Mechanism and New Events

New events (Observer.scala)

/** Fired after any move where the 50-move rule threshold is reached (halfMoveClock >= 100). */
case class FiftyMoveRuleAvailableEvent(
  board: Board,
  history: GameHistory,
  turn: Color
) extends GameEvent

/** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent(
  board: Board,
  history: GameHistory,
  turn: Color
) extends GameEvent

Claim handling in GameEngine.processUserInput

A new "draw" case is added before the move-parsing fallthrough:

case "draw" =>
  if currentHistory.halfMoveClock >= 100 then
    currentBoard   = Board.initial
    currentHistory = GameHistory.empty
    currentTurn    = Color.White
    invoker.clear()
    notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
  else
    notifyObservers(InvalidMoveEvent(
      currentBoard, currentHistory, currentTurn,
      "Draw cannot be claimed: the 50-move rule has not been triggered."
    ))

The game state resets to initial (same pattern as Checkmate and Stalemate). The command invoker is cleared so undo/redo history does not survive the draw claim.

Availability notification in GameEngine

After any move that results in Moved or MovedInCheck, the engine checks whether the threshold has been crossed:

if newHistory.halfMoveClock >= 100 then
  notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))

This fires immediately after the MoveExecutedEvent (or CheckDetectedEvent) for that move.


Section 4: Files Changed

File Change
modules/core/src/main/scala/de/nowchess/chess/logic/GameHistory.scala Add halfMoveClock field; extend addMove with wasPawnMove/wasCapture flags; make base overload private
modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala Compute and pass flags in applyNormalMove and completePromotion
modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala Add FiftyMoveRuleAvailableEvent and DrawClaimedEvent
modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala Handle "draw" input; fire FiftyMoveRuleAvailableEvent after eligible moves
modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala New test suite for clock update rules
modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala Tests for clock values in applyNormalMove and completePromotion
modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala Tests for "draw" command and FiftyMoveRuleAvailableEvent

EnPassantCalculator, CastlingRightsCalculator, MoveValidator, GameRules, and their tests are not touched.


Section 5: Testing

GameHistoryTest

  • Clock starts at 0
  • Clock increments on a normal (non-pawn, non-capture) move
  • Clock resets to 0 on a pawn move (wasPawnMove = true)
  • Clock resets to 0 on a capture (wasCapture = true)
  • Clock resets to 0 when both flags are true (en passant)
  • Clock carries correctly across multiple sequential moves

GameControllerTest

  • applyNormalMove with a non-pawn, non-capture produces history.halfMoveClock = 1
  • applyNormalMove with a pawn move produces history.halfMoveClock = 0
  • applyNormalMove with a capture produces history.halfMoveClock = 0
  • completePromotion always produces history.halfMoveClock = 0

GameEngineTest

  • processUserInput("draw") fires DrawClaimedEvent and resets state when halfMoveClock >= 100
  • processUserInput("draw") fires InvalidMoveEvent when halfMoveClock < 100
  • A successful non-pawn, non-capture move that brings the clock to exactly 100 fires FiftyMoveRuleAvailableEvent
  • A successful move that does not reach 100 does not fire FiftyMoveRuleAvailableEvent