Many Faces, One Name

In ancient myths, gods often appeared in different forms. Zeus might be a swan, a bull, or a shower of gold. One name, many manifestations. The essence remained constant; the appearance changed.

Functions can have this quality too. The same name---move---might mean different things for different pieces. A knight moves in an L. A bishop moves diagonally. The name unifies; the behavior varies.

The Problem of Piece Movement

Each chess piece moves differently:

python
    knight: L-shape (2 squares one way, 1 square perpendicular)
    bishop: diagonal lines, any distance
    rook:   straight lines, any distance
    queen:  diagonal or straight, any distance
    king:   one square in any direction
    pawn:   forward one (or two from start), captures diagonally

How do we write code that handles all pieces uniformly? Let's discover the answer through design---iteratively, as real programmers do.

Designing Move Validation

Our context: the game will process a move event. Before applying it, we must check if it's valid. So we need:

haskell
    validate_move(piece, from, to) -> Boolean

The function takes a piece, its starting position, and where it wants to go. It returns whether that move is legal.

Let's try implementing this, starting with the easiest pieces and seeing what we discover.

The King: Simple Geometry

The king moves one square in any direction. This is pure geometry:

sql
    validate_move for king:
        distance_x = |to.x - from.x|
        distance_y = |to.y - from.y|
        distance_x <= 1 AND distance_y <= 1 AND (to != from)

This works. The king can move to any adjacent square. We haven't handled castling yet, but we'll note that for later.

The Knight: Also Geometry

The knight's L-shape is also just distance:

sql
    validate_move for knight:
        dx = |to.x - from.x|
        dy = |to.y - from.y|
        (dx == 2 AND dy == 1) OR (dx == 1 AND dy == 2)

Two pieces down, feeling confident. Let's try more.

The Rook and Bishop: Paths Matter

The rook moves in straight lines, the bishop diagonally. But now something new appears: the path must be clear.

sql
    validate_move for rook:
        on_same_rank_or_file(from, to) AND path_clear(from, to)
    
    validate_move for bishop:
        on_diagonal(from, to) AND path_clear(from, to)

Wait. path_clear needs to know what's on the board between from and to. We need the board state! Our function signature needs to grow:

haskell
    validate_move(piece, from, to, board) -> Boolean

This is design in action. We thought we understood the problem, but the rook revealed hidden complexity.

The Pawn: The Troublemaker

Now the pawn. This innocent piece is about to break our model.

    validate_move for pawn:
        if capturing:
            moving diagonally forward one square
        else:
            moving straight forward one square
            OR moving straight forward TWO squares if... ?

If what? The pawn can move two squares only from its starting position. But wait---if a pawn is moved forward, then moved back to its starting position, can it move two squares again?

In real chess, no. The two-square move is only available on the pawn's first move, not when it happens to be on the starting rank.

So we need: "Has this pawn moved before?"

Where do we store that? Several options:

Let's add a simple flag:

  • Each piece tracks whether it has moved
  • The board maintains a list of pieces that have moved
  • We store the full game history (events!)
sql
    Piece:
        kind: PieceKind
        color: Color
        has_moved: Boolean
    
    validate_move for pawn:
        if capturing:
            // diagonal capture logic
        else if not piece.has_moved AND from is starting rank:
            can move 1 or 2 squares forward
        else:
            can move 1 square forward

The has_moved flag also helps with castling! The king and rook must both be unmoved. Our design choice serves multiple purposes.

What Parameters Do We Really Need?

Now a deeper question: do we need all three parameters---piece, from, to?

Consider: "move pawn e2 to e4". We have the start position. The board knows which piece is on e2. So "move e2 to e4" is sufficient!

Should we simplify?

python
    // Option A: explicit piece
    validate_move(piece, from, to, board)
    
    // Option B: derive piece from position
    validate_move(from, to, board)
        piece = board[from]
        ...

Both work. Option B is more minimal. But Option A makes the code clearer---we're explicitly talking about a piece. The start position becomes an indirect way to reference the piece. In programming, we often face this choice between minimalism and clarity.

I'd keep the piece explicit. The cost is low, and it makes the code read like the domain: "Can this piece move from here to there?"

The Lessons of Iterative Design

What did we discover?

  1. Problems reveal themselves gradually. We started thinking "just geometry" and discovered we needed board state, piece history, and special rules.
  2. Design decisions compound. Each choice (like has_moved) enables or constrains future code. Small decisions accumulate into system shape.
  3. Adapt like water. As Bruce Lee said: "Be like water." Our design must flow around obstacles, reshaping as we learn more.

Complex systems become rigid over time. Each dependency, each assumption, each shortcut adds weight. Eventually the system resists change---entropy wins. The discipline of programming is managing this: keeping the system malleable, isolating dependencies, making the cost of change low.

The Naive Approach: Big Conditional

sql
    can_reach(piece, from, to):
        if piece.kind == knight:
            ... knight logic ...
        else if piece.kind == bishop:
            ... bishop logic ...
        else if piece.kind == rook:
            ... rook logic ...
        // ... and so on

This becomes unwieldy. The piece-specific logic is scattered across many conditionals throughout the codebase.

The Polymorphic Approach

Instead, we can define movement per piece kind:

sql
    can_reach for knight:
        from and to differ by (2,1) or (1,2) in any direction
    
    can_reach for bishop:
        from and to on same diagonal AND path is clear
    
    can_reach for rook:
        from and to on same rank or file AND path is clear
    
    can_reach for queen:
        (can_reach as rook) OR (can_reach as bishop)
    
    can_reach for king:
        from and to differ by at most 1 in each direction
    
    can_reach for pawn:
        ... pawn-specific rules ...

Now can_reach is one name with multiple implementations. The system selects the right one based on the piece's type.

Protocols: The Contract

What makes this work is a shared contract---an agreement about what operations exist:

sql
    Piece protocol:
        can_reach(from: Square, to: Square): Boolean
        symbol: Text          // e.g. "N" for knight
        value: Number         // material value (knight = 3, etc.)

Any piece kind that implements this protocol can be used wherever a "piece" is expected. The protocol defines the shape of the behavior; each kind provides the substance.

Type-Based Dispatch

When we call a polymorphic function, the system must decide which implementation to use. This is called dispatch.

python
    piece = board[e4]                    // might be any piece
    can_move = can_reach(piece, e4, e5)  // which can_reach?
    
    // Dispatch based on piece.kind:
    // - if knight: use knight's can_reach
    // - if bishop: use bishop's can_reach
    // - etc.

The caller doesn't need to know what kind of piece it is. They just call can_reach, and the right behavior emerges.

Polymorphism Enables Abstraction

This is why polymorphism matters: it lets us write abstract code.

elixir
    // Works for ANY piece, without knowing what kind:
    get_reachable_squares(piece, from, board):
        all_squares
        |> filter((sq) => can_reach(piece, from, sq))
        |> filter((sq) => path_clear(board, from, sq))

This function works for knights, bishops, rooks---any piece that fulfills the protocol. If we add a new piece (fairy chess, anyone?), this function still works, unchanged.

Adding New Pieces

With polymorphism, adding a new piece kind is localized:

    // New piece: the Amazon (combines queen and knight)
    
    can_reach for amazon:
        (can_reach as queen) OR (can_reach as knight)
    
    symbol for amazon: "A"
    value for amazon: 12

No existing code changes. The new piece automatically works with all functions that operate on the Piece protocol.

Multiple Dispatch

Sometimes behavior depends on multiple types:

python
    // Capturing depends on BOTH the capturing piece and the target
    can_capture(attacker, target):
        ... depends on both pieces ...
    
    // Pawn captures differently than it moves
    // King can't capture protected pieces
    // etc.

This is multiple dispatch---selecting the implementation based on multiple arguments' types.

Polymorphism lets one name have many implementations. Protocols define the contract. Together, they enable abstract, extensible code.

But we've been building a chess game. What if we want to build any board game? In the next part, we step back and look for the patterns that transcend chess.