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:
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 diagonallyHow 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:
validate_move(piece, from, to) -> BooleanThe 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:
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:
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.
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:
validate_move(piece, from, to, board) -> BooleanThis 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!)
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?
// 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?
- Problems reveal themselves gradually. We started thinking "just geometry" and discovered we needed board state, piece history, and special rules.
- Design decisions compound. Each choice (like
has_moved) enables or constrains future code. Small decisions accumulate into system shape. - 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
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 onThis 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:
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:
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.
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.
// 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: 12No 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:
// 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.