9.0 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: FEN Integration
FenExporter.gameStateToFen and FenParser.parseFen already handle halfMoveClock at the GameState level — no changes to those files are needed.
The bridge between GameHistory.halfMoveClock and GameState.halfMoveClock is a caller responsibility:
FEN export (writing): When constructing a GameState for FEN export, pass halfMoveClock = history.halfMoveClock. Since GameEngine already exposes def history: GameHistory, this works automatically once the field is populated:
GameState(
piecePlacement = FenExporter.boardToFen(engine.board),
activeColor = engine.turn,
...,
halfMoveClock = engine.history.halfMoveClock,
...
)
FEN import (reading): When loading from a parsed GameState, initialise the engine with a GameHistory carrying the parsed clock:
val gs = FenParser.parseFen(fenString).get
new GameEngine(
initialBoard = FenParser.parseBoard(gs.piecePlacement).get,
initialHistory = GameHistory(halfMoveClock = gs.halfMoveClock),
initialTurn = gs.activeColor
)
A round-trip test is added to FenExporterTest / FenParserTest verifying that a non-zero clock survives export → import.
Section 5: PGN Integration
PgnExporter.exportGame currently hardcodes " *" as the game termination marker. PGN standard requires the marker to match the Result header (1-0, 0-1, 1/2-1/2, or *).
Change to PgnExporter
Replace the hardcoded " *" with the value from the Result header:
val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
Draw claim result
When DrawClaimedEvent is handled by a caller that exports PGN, it should pass:
Map("Result" -> "1/2-1/2", ...)
The move text will then end with 1/2-1/2, which is correct per PGN standard for a drawn game.
A test is added to PgnExporterTest verifying that exportGame with "Result" -> "1/2-1/2" produces a move text ending in 1/2-1/2.
Section 6: 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/main/scala/de/nowchess/chess/notation/PgnExporter.scala |
Derive termination marker from Result header instead of hardcoding * |
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 |
modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala |
Test for 1/2-1/2 termination marker |
modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala |
Round-trip test: non-zero halfMoveClock survives FEN export → import |
EnPassantCalculator, CastlingRightsCalculator, MoveValidator, GameRules, and their tests are not touched.
Section 7: 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
PgnExporterTest
exportGamewith"Result" -> "1/2-1/2"produces move text ending in1/2-1/2exportGamewith noResultheader still produces*as before (backward-compatible)
FenExporterTest
- Round-trip: a
GameHistorywithhalfMoveClock = 42exported to FEN and re-parsed yieldshalfMoveClock = 42