The General and the Specific

A chess board is 8×8. A Go board is 19×19. A checkers board is 8×8 but uses only half the squares. An Othello board is 8×8.

These are all board games. They share something essential. Can we find it?

What Board Games Share

Every board game we've mentioned has:

python
    A board:        some arrangement of positions
    Pieces:         things that occupy positions
    Players:        who takes turns (or acts simultaneously)
    Rules:          what actions are valid
    State:          the current configuration
    Win condition:  how the game ends

Chess fills in these slots specifically. But the slots themselves are general.

The Abstract Board Game

yaml
    BoardGame:
        Board:
            positions: Set of Position
            adjacency: Position → Set of Position  // which positions connect
        
        Piece:
            owner: Player
            kind: PieceKind
        
        State:
            board_contents: Position → (Piece OR empty)
            current_player: Player
            ... game-specific state ...
        
        valid_actions(state): Set of Action
        apply_action(state, action): State
        is_terminal(state): Boolean
        winner(state): Maybe Player

Chess as an Instance

Chess fills the abstract framework:

    Chess implements BoardGame:
        Board:
            positions: 64 squares (a1 through h8)
            adjacency: defined by piece movement rules
        
        Piece:
            kinds: king, queen, rook, bishop, knight, pawn
            owner: white or black
        
        State:
            64 squares each empty or containing a piece
            current_player: white or black
            castling_rights, en_passant, etc.
        
        valid_actions(state):
            all legal moves for current player
        
        apply_action(state, move):
            state with move applied
        
        is_terminal(state):
            is_checkmate(state) OR is_stalemate(state) OR is_draw(state)
        
        winner(state):
            if checkmate: opponent of player in checkmate
            else: Nothing (draw or ongoing)

Go as Another Instance

Go fills the same framework differently:

    Go implements BoardGame:
        Board:
            positions: 361 intersections (19×19)
            adjacency: orthogonal neighbors
        
        Piece:
            kinds: stone (just one kind!)
            owner: black or white
        
        State:
            361 intersections, each empty or containing a stone
            current_player: black or white
            captured_count, ko_position, etc.
        
        valid_actions(state):
            place stone on empty intersection (if not suicide or ko)
            OR pass
        
        apply_action(state, action):
            if placing: add stone, remove captured groups
            if passing: switch player
        
        is_terminal(state):
            both players passed consecutively
        
        winner(state):
            count territory and captures, higher score wins
A Go position (9×9 board shown; standard Go uses 19×19). Black and white stones compete for territory.

The Power of the Abstraction

With this abstraction, we can write code that works for any board game:

wollok
    // Works for chess, Go, checkers, Othello...
    play_game(game, player_white, player_black):
        state = game.initial_state
        
        while not game.is_terminal(state):
            player = if state.current_player == white 
                     then player_white 
                     else player_black
            
            action = player.choose_action(state, game.valid_actions(state))
            state = game.apply_action(state, action)
        
        return game.winner(state)

A Design Choice: Who Validates?

Notice something subtle: the game provides valid_actions(state)---a list of all legal moves. The player then chooses from this list.

This is different from our earlier design in move validation! There we imagined:

python
    // Earlier approach: player proposes, game validates
    player_move = player.propose_move()    // e.g., "e2 to e4"
    if game.is_valid(state, player_move):
        state = game.apply(state, player_move)
    else:
        // handle invalid move

Both work. But they create very different architectures.

Connecting to the User Interface

Where this choice matters most: the UI. Imagine a graphical chess application.

Approach A: Validate after selection

The player selects a piece and a destination square. The game validates. If invalid, show an error.

erlang
    UI workflow (validate after):
        1. User clicks piece at e2
        2. User clicks destination e5
        3. Game checks: is e2-e5 valid?
        4. If no: show error, user tries again
        5. If yes: apply move

Approach B: Show valid moves upfront

The player selects a piece. The UI immediately shows which squares are valid targets. The player can only select from those.

    UI workflow (valid moves upfront):
        1. User clicks piece at e2
        2. Game computes: valid_targets = [e3, e4]
        3. UI highlights e3 and e4
        4. User can only click highlighted squares
        5. Apply the selected (guaranteed valid) move

The second approach feels better---no errors, immediate feedback, guided interaction. But it requires the game to expose valid_actions(state) or valid_targets_for(piece).

The Trade-off

In Approach A, the UI is dumb. It just sends positions. The game logic is centralized in validation.

In Approach B, the UI must understand valid moves to show them. Does it compute them itself? Then we have duplicate logic---move rules in both game and UI. Or the game provides them, and the UI just displays.

Our abstract BoardGame with valid_actions(state) supports Approach B naturally. The UI asks the game: "What can I do?" The game answers. The UI presents choices. No validation needed---every choice is already valid.

This is the power of the abstraction: it doesn't just enable AI to play any game. It enables any interface---graphical, text, voice, gesture---to present valid options without understanding game rules.

AI That Plays Any Game

The abstraction enables generic AI. Consider the minimax algorithm---a decision-making strategy for two-player games where one player's gain is the other's loss.

The insight is beautifully simple: I want to maximize my advantage, while my opponent wants to minimize it (from my perspective). So at my turn, I pick the move that leads to the highest value. At my opponent's turn, I assume they'll pick the move that leads to the lowest value for me.

The algorithm explores possible futures like a tree---each branch a possible move, each level alternating between players. At the leaves (where we stop looking), we evaluate how good the position is. Then we work backward: minimize at opponent's levels, maximize at ours.

elixir
    // Minimax works for ANY two-player zero-sum game
    minimax(game, state, depth, maximizing):
        if depth == 0 OR game.is_terminal(state):
            return evaluate(game, state)
        
        if maximizing:
            game.valid_actions(state)
            |> map((a) => minimax(game, game.apply_action(state, a), 
                                  depth-1, false))
            |> max
        else:
            game.valid_actions(state)
            |> map((a) => minimax(game, game.apply_action(state, a), 
                                  depth-1, true))
            |> min

This same minimax code can play chess, checkers, Othello, Connect Four---any game that fits the framework.

What We Gain

From abstraction, we gain:

  1. Reuse: Game loops, AI, replay systems work across all games
  2. Clarity: The framework reveals what board games have in common
  3. Extensibility: New games plug into existing infrastructure
  4. Testing: Test the framework once, benefit everywhere

What We Lose

Abstraction has costs:

  1. Indirection: More layers between code and behavior
  2. Constraints: The framework may not fit every game perfectly
  3. Complexity: Understanding requires grasping the abstract structure

Not everything is a board game. The abstraction has boundaries.

The Art of Abstraction

Finding the right abstraction is an art:

  • Too specific: only works for one case (useless)
  • Too general: so vague it provides no guidance (also useless)
  • Just right: captures the common essence while allowing variation

Our board game framework works because:

  • Many games genuinely share this structure
  • The variation points (board shape, piece types, rules) are cleanly separated
  • The interface is simple: a few functions, clearly defined

The Complete Game: Chess in Full

Let's see the complete chess implementation---our abstraction made concrete. This is not pseudocode for illustration; it's a complete, working specification.

The Core Types

sql
    Color = white | black
    
    PieceKind = king | queen | rook | bishop | knight | pawn
    
    Piece:
        kind: PieceKind
        color: Color
        has_moved: Boolean
    
    Square = (file: a..h, rank: 1..8)
    
    Move:
        piece: Piece
        from: Square
        to: Square
        promotion: Maybe PieceKind    // for pawn promotion
        is_castling: Boolean
        is_en_passant: Boolean

The Board

    Board = Square -> Maybe Piece    // each square: piece or empty
    
    initial_board:
        // White pieces
        a1: Rook(white), b1: Knight(white), c1: Bishop(white),
        d1: Queen(white), e1: King(white), f1: Bishop(white),
        g1: Knight(white), h1: Rook(white)
        a2..h2: Pawn(white)
        
        // Black pieces  
        a8: Rook(black), b8: Knight(black), c8: Bishop(black),
        d8: Queen(black), e8: King(black), f8: Bishop(black),
        g8: Knight(black), h8: Rook(black)
        a7..h7: Pawn(black)
        
        // All other squares: empty

Game State

yaml
    ChessState:
        board: Board
        current_player: Color
        castling_rights:
            white_kingside: Boolean
            white_queenside: Boolean
            black_kingside: Boolean
            black_queenside: Boolean
        en_passant_target: Maybe Square
        halfmove_clock: Number        // for fifty-move rule
        fullmove_number: Number
    
    initial_state = ChessState(
        board: initial_board,
        current_player: white,
        castling_rights: all true,
        en_passant_target: Nothing,
        halfmove_clock: 0,
        fullmove_number: 1
    )

Movement Patterns

python
    // Direction vectors for piece movement
    orthogonal = [(0,1), (0,-1), (1,0), (-1,0)]
    diagonal = [(1,1), (1,-1), (-1,1), (-1,-1)]
    knight_jumps = [(2,1), (2,-1), (-2,1), (-2,-1),
                   (1,2), (1,-2), (-1,2), (-1,-2)]
    
    // Squares reachable by sliding in a direction
    slide(from, direction, board):
        squares = []
        current = from + direction
        while current is on board:
            squares += current
            if board[current] is occupied:
                break
            current = current + direction
        return squares
    
    // Squares a piece can reach (ignoring check)
    reachable(piece, from, board):
        match piece.kind:
            king:   neighbors(from)          // all 8 adjacent
            queen:  slide_all(from, orthogonal + diagonal, board)
            rook:   slide_all(from, orthogonal, board)
            bishop: slide_all(from, diagonal, board)
            knight: from + each of knight_jumps (if on board)
            pawn:   pawn_moves(piece, from, board)
sql
    pawn_moves(pawn, from, board):
        direction = if pawn.color == white then +1 else -1
        moves = []
        
        // Forward one
        one_ahead = (from.file, from.rank + direction)
        if board[one_ahead] is empty:
            moves += one_ahead
            
            // Forward two (from starting rank, if path clear)
            if not pawn.has_moved:
                two_ahead = (from.file, from.rank + 2*direction)
                if board[two_ahead] is empty:
                    moves += two_ahead
        
        // Diagonal captures
        for capture_file in [from.file - 1, from.file + 1]:
            target = (capture_file, from.rank + direction)
            if board[target] contains enemy piece:
                moves += target
        
        return moves

Move Validation

sql
    is_legal(move, state):
        piece = move.piece
        
        // Basic reachability
        if move.to not in reachable(piece, move.from, state.board):
            return false
        
        // Can't capture own pieces
        if state.board[move.to] contains same color:
            return false
        
        // Would this leave our king in check?
        new_state = apply_move(move, state)
        if is_in_check(move.piece.color, new_state):
            return false
        
        return true
    
    is_in_check(color, state):
        king_position = find_king(color, state.board)
        opponent = opposite(color)
        
        // Is any opponent piece attacking the king?
        for (square, piece) in state.board:
            if piece.color == opponent:
                if king_position in reachable(piece, square, state.board):
                    return true
        return false

Special Moves

elixir
    // Castling
    can_castle(state, side):  // side = kingside | queenside
        color = state.current_player
        rights = state.castling_rights
        
        // Must have rights
        if not rights[color, side]: return false
        
        // King and rook squares
        (king_from, king_to, rook_from, rook_to, path) = 
            castling_squares(color, side)
        
        // Path must be clear
        if any square in path is occupied: return false
        
        // King can't be in check, move through check, or end in check
        if is_in_check(color, state): return false
        for square in [king_from, path..., king_to]:
            if square_is_attacked(square, opposite(color), state):
                return false
        
        return true
    
    // En passant
    can_en_passant(pawn, from, to, state):
        state.en_passant_target == to AND
        to is diagonal from from AND
        pawn is a pawn

Applying Moves

sql
    apply_move(move, state):
        new_board = state.board
        
        // Move the piece
        new_board[move.from] = empty
        moved_piece = move.piece with { has_moved: true }
        
        // Handle promotion
        if move.promotion:
            moved_piece = Piece(move.promotion, move.piece.color, true)
        
        new_board[move.to] = moved_piece
        
        // Handle castling (also move the rook)
        if move.is_castling:
            (rook_from, rook_to) = castling_rook_squares(move)
            new_board[rook_from] = empty
            new_board[rook_to] = state.board[rook_from] with { has_moved: true }
        
        // Handle en passant (remove captured pawn)
        if move.is_en_passant:
            captured_pawn_square = (move.to.file, move.from.rank)
            new_board[captured_pawn_square] = empty
        
        // Update castling rights
        new_rights = update_castling_rights(state.castling_rights, move)
        
        // Update en passant target
        new_en_passant = 
            if move.piece.kind == pawn AND |move.to.rank - move.from.rank| == 2:
                (move.to.file, (move.from.rank + move.to.rank) / 2)
            else:
                Nothing
        
        return ChessState(
            board: new_board,
            current_player: opposite(state.current_player),
            castling_rights: new_rights,
            en_passant_target: new_en_passant,
            halfmove_clock: updated_clock(move, state),
            fullmove_number: state.fullmove_number + 
                             (if state.current_player == black then 1 else 0)
        )

Game Outcomes

elixir
    valid_moves(state):
        all_pieces_of(state.current_player, state.board)
        |> flatmap((piece, square) => 
               reachable(piece, square, state.board)
               |> map((to) => Move(piece, square, to))
               |> filter((move) => is_legal(move, state)))
    
    is_checkmate(state):
        is_in_check(state.current_player, state) AND
        valid_moves(state) is empty
    
    is_stalemate(state):
        not is_in_check(state.current_player, state) AND
        valid_moves(state) is empty
    
    is_draw(state):
        is_stalemate(state) OR
        state.halfmove_clock >= 100 OR     // fifty-move rule
        is_insufficient_material(state) OR
        is_threefold_repetition(state)     // needs history
    
    game_result(state):
        if is_checkmate(state):
            winner: opposite(state.current_player)
        else if is_draw(state):
            draw
        else:
            ongoing

The Complete Game

    Chess implements BoardGame:
        initial_state = initial_state
        
        valid_actions(state) = valid_moves(state)
        
        apply_action(state, move) = apply_move(move, state)
        
        is_terminal(state) = 
            is_checkmate(state) OR is_draw(state)
        
        winner(state) = 
            if is_checkmate(state): 
                Just(opposite(state.current_player))
            else: 
                Nothing

This is the complete chess game. Every legal move, every special rule, every game outcome---all derived from these definitions. The same minimax algorithm that plays tic-tac-toe can now play chess.

We've traveled from concrete chess moves to abstract board games, from specific implementations to general frameworks. In the final part, we look ahead---to a new era where the boundary between human intent and machine execution is being redrawn.