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

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

  • 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

PgnExporterTest

  • exportGame with "Result" -> "1/2-1/2" produces move text ending in 1/2-1/2
  • exportGame with no Result header still produces * as before (backward-compatible)

FenExporterTest

  • Round-trip: a GameHistory with halfMoveClock = 42 exported to FEN and re-parsed yields halfMoveClock = 42