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
applyNormalMovewith a non-pawn, non-capture produceshistory.halfMoveClock = 1applyNormalMovewith a pawn move produceshistory.halfMoveClock = 0applyNormalMovewith a capture produceshistory.halfMoveClock = 0completePromotionalways produceshistory.halfMoveClock = 0
GameEngineTest
processUserInput("draw")firesDrawClaimedEventand resets state whenhalfMoveClock >= 100processUserInput("draw")firesInvalidMoveEventwhenhalfMoveClock < 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