This section adds a lot of “boring” code that is not really related to frontend development. If you find boring code boring and are easily bored, just skip this section and get the code from my repository.

Chess players don’t find every move splendid, and they have their own shorthand way of keeping a record. It’s called Standard Algebraic Notation (SAN). Instead of writing down the coordinates of the source square and the target square, they just write down the type of piece that moved and its destination square, for instance Qg7 for a queen’s move to the g7 square. The standard abbreviations are K, Q, R, B and N (because K is already taken) for King, Queen, Rook, Bishop and Knight. Pawn moves are indicated only by target square, and in the event of a capture, also by the source file (because pawns capture diagonally).

When a move needs to be disambiguated because more than one piece of the same type can move to the same square, the strategy is as follows:

disambiguate by adding a hint for the file of origin: Qg7

if it is still ambiguous, try the rank of origin: Qhg7

if both strategies fail, add both file and rank: Qh8g7 .

The last disambiguation strategy is only needed when a player has promoted two pawns to queens. (Can you prove that statement?)

Finally, if a move is a capture, an x is inserted after the piece type, if a move puts the opponent’s king into check, + is added to the move, and if a move checkmates the opponent, # is added. For pawn moves, x is inserted between original and destination file, and for pawn promotions, =Q (or type of other piece if not a queen) is added. For instance, capturing with a pawn from e7 to f8 promoting to a rook and delivering checkmate, is written exf8=R# .

There are two special moves, kingside and queenside castle (involving the king and a rook), written O-O and O-O-O respectively. Phew, I think I covered all the little corner cases now, let’s see if we can implement that. (I actually described SAN as used by the PGN format, which is slightly different from the official SAN as prescribed by the world chess organization FIDE.)

We’ll be adding all our code to src/Chess.ml . Let’s start by defining a few useful types and functions. While I was it, I added make_move' because I was annoyed of having to type the extra 0 at the end. (Probably writing the defintion and my justification spoils all the saved keystrokes now.)

type capture = bool type promotion = piece_type option type short_move = piece_type * file option * rank option * square * capture type long_move = | Piece_move of piece_type * square * square * capture | Pawn_move of file * square * capture * promotion | Ochess_move of move type check = | Check | Checkmate | No_check let make_move' position move = make_move position move 0 let char_of_file file = "abcdefgh" . [ file ] let char_of_rank rank = "12345678" . [ rank ]

We will use O’Chess to compute a list of long_move for a given position, and then use the disambiguation strategies listed above to compute a corresponding list of short_move . File and rank disambiguation is represented by option types. Pawn moves always have the file of origin associated to them in case we need to display it for a capturing move, and optionally a promotion piece.

let check_or_checkmate position move = let position' = make_move' position move in let checked = king_checked position' position'.turn in if checked then match legal_moves position' with | [] -> Checkmate | _ -> Check else No_check let long_move position move = match move with | Move ( s_file , s_rank , t_file , t_rank ) -> begin match position.ar. ( s_file ) . ( s_rank ) with | Piece ( Pawn , _ ) -> (* a pawn move is a capture if and only if it changes files *) Pawn_move ( s_file , ( t_file , t_rank ), ( s_file <> t_file ), None ) | Piece ( p_type , _ ) -> let capture = match position.ar. ( t_file ) . ( t_rank ) with | Piece _ -> true | Empty -> false in Piece_move ( p_type , ( s_file , s_rank ), ( t_file , t_rank ), capture ) | Empty -> raise Illegal_move end | Queenside_castle -> Ochess_move Queenside_castle | Kingside_castle -> Ochess_move Kingside_castle | Promotion ( p_type , s_file , t_file ) -> let t_rank = match position.turn with | White -> 7 | Black -> 0 in Pawn_move ( s_file , ( t_file , t_rank ), ( s_file <> t_file ), Some p_type )

The long_move function converts the O’Chess move representation into the long_move type by adding the capture flag and straightening out a few kinks. In particular, it separates Move into Pawn_move and Piece_move , and groups the latter together with Promotion . There is a case that should never happen (moving a piece from an empty square), so we raise an exception (from O’Chess) in that case.

The check_or_checkmate function returns check/checkmate info for a given move by trying it in the given position and determining whether after the move, the other player’s king will be in check. If it is, and there are no legal moves, it’s checkmate!

Now we need to compute the disambiguated SAN for a given move. We achieve this by trying each disambiguation strategy in turn.

(* a short move is good if there is a unique long move that it matches *) let unique move_list short_move = List. filter ( unify_move short_move ) move_list |> List. length = 1 (* return a short move for a piece move, else None *) (* following order of preference: Qg7, Qhg7, Q8g7, Qh8g7 *) let short_move_of_long_move move_list long_move = let unique' = unique move_list in match long_move with | Piece_move ( p_type , ( s_file , s_rank ), target , capture ) -> let qg7 = ( p_type , None , None , target , capture ) in if unique' qg7 then Some qg7 else let qhg7 = ( p_type , Some s_file , None , target , capture ) in if unique' qhg7 then Some qhg7 else let q8g7 = ( p_type , None , Some s_rank , target , capture ) in if unique' q8g7 then Some q8g7 else (* Qh8g7 *) Some ( p_type , Some s_file , Some s_rank , target , capture ) | _ -> None

We still have to write a function unify_move that determines if a short_move matches a given long_move though. We just check if the destination square matches and if the optional disambiguation hints can be unified (everything can be unified with None ).

let unify value hint = match value , hint with | _ , None -> true (* everything unifies with None *) | x , Some y when x = y -> true | _ -> false (* is the candidate a possible short form of a long move? *) let unify_move short_move long_move = match long_move with | Piece_move ( long_p_type , long_source , long_target , _ ) -> (* capture irrelevant *) let long_file , long_rank = long_source in let short_p_type , short_file_hint , short_rank_hint , short_target , _ = short_move in short_target = long_target && short_p_type = long_p_type && unify long_file short_file_hint && unify long_rank short_rank_hint | _ -> false (* we can safely ignore pawn moves and castling *)

Finally, we’re ready to calculate the SAN string for a given move. There’s a lot of pattern matching going on here, but if you look closely, you will find that it is a very straightforward formulation of the SAN definition. There is a case that should never happen because when long_move is a Piece_move , the short_move_option cannot be None , but that is impossible for the compiler to figure out.

let san_of_move' position move_list move = let long_move = long_move position move and check = check_or_checkmate position move in let short_move_option = short_move_of_long_move move_list long_move in let san = match short_move_option , long_move with | None , Ochess_move Queenside_castle -> "O-O-O" | None , Ochess_move Kingside_castle -> "O-O" | None , Pawn_move ( file , ( t_file , t_rank ), capture , promotion ) -> Printf. sprintf "%s%c%c%s" ( if capture then char_of_file file |> Printf. sprintf "%cx" else "" ) ( char_of_file t_file ) ( char_of_rank t_rank ) ( match promotion with | None -> "" | Some p_type -> char_of_piece_type p_type |> Printf. sprintf "=%c" ) | Some ( p_type , file_hint , rank_hint , ( t_file , t_rank ), capture ), _ -> Printf. sprintf "%c%s%s%s%c%c" ( char_of_piece_type p_type ) ( match file_hint with | None -> "" | Some file -> char_of_file file |> Printf. sprintf "%c" ) ( match rank_hint with | None -> "" | Some rank -> char_of_rank rank |> Printf. sprintf "%c" ) ( if capture then "x" else "" ) ( char_of_file t_file ) ( char_of_rank t_rank ) | _ -> raise Illegal_move in san ^ match check with | Check -> "+" | Checkmate -> "#" | No_check -> ""

Next, we define two ways of getting SAN strings. The legal_moves_with_san function uses O’Chess to enumerate the legal moves and generate an association list of SAN and O’Chess moves. An association list is a list of (key, value) pairs, and the List module provides some useful functions for searching the list for a given key and the like. If your association lists start getting big, you may want to use a hashmap or other container that has faster access than O(n), but lists of legal moves are typically not longer than 30, so it shouldn’t be a problem.

The san_of_move function just returns the SAN string for a given move in a given position.

let moves_assoc_list position moves = let long_moves = moves |> List. map ( long_move position ) in let san_moves = moves |> List. map ( san_of_move' position long_moves ) in List. combine moves san_moves let legal_moves_with_san position = legal_moves position |> moves_assoc_list position let san_of_move position move = let move_list = legal_moves position |> List. map ( long_move position ) in san_of_move' position move_list move

Update the update function of src/Game.ml :

let update model = function | Move move -> begin try let san = Chess. san_of_move model.position move in let position = Chess. make_move model.position move 0 in { model with position ; moves = simple_move move san :: model.moves }, Cmd. none with Chess. Illegal_move -> model , Cmd. none end (* ... *)

And moves will be logged in Standard Algebraic Notation.