Chess Pieces —
Each One Knows
Its Own Moves.
A rook, a bishop and a knight are all Piece objects. The board loop asks each one for its legal_moves() — and every piece answers in its own way. That is polymorphism, played out on a chessboard.
This live project is a companion to the Polymorphism in Python guide. You'll see six subclasses override the same two methods, run the real Python code, and play with a live board where each piece moves according to its own rules.
Progress Tracker
Tick off each milestone as you complete it. Progress is saved in this browser.
🎯 Your Journey Through Chess Pieces
0%Why Chess Is a Perfect Polymorphism Example
♟️ One Question, Many Answers
A chess engine needs to know: "Where can this piece legally move?"
There are six kinds of pieces, and each has a completely different rule. A naive engine writes one giant if / elif block on piece type, repeated everywhere moves are needed.
A polymorphic engine writes the question once — piece.legal_moves(board) — and gives each piece class its own answer. Adding a new piece (say, a fairy-chess "Princess") means writing one class, not hunting down every if ladder.
This is the same idea behind the Polymorphism guide, but grounded in a game you already know.
The Two Methods Every Piece Shares
Every class in our engine inherits from a base Piece that declares the interface. The board code only ever calls these:
| Method | What it returns | Who uses it |
|---|---|---|
legal_moves(board) | List of (row, col) destinations the piece can reach | Move validation, highlighting, AI search |
symbol() | A Unicode character used to render the piece | The board renderer |
The payoff: the board code never asks "what kind of piece is this?" It just calls piece.legal_moves(board). Every piece answers for itself.
Meet the Pieces
Each of these is a subclass of Piece. Each overrides legal_moves() with its own rule and symbol() with its own glyph.
One square in any direction.
Any distance, straight or diagonal.
Any distance, straight lines only.
Any distance, diagonals only.
L-shape: two one way, one the other. Jumps.
Forward one (or two from start), diagonally to capture.
How Each Override Differs
| Piece | Direction set | Distance | Special |
|---|---|---|---|
| King | All 8 directions | Exactly 1 | — |
| Queen | All 8 directions | 1…7 (slide) | Stops at blockers |
| Rook | 4 straight directions | 1…7 (slide) | Stops at blockers |
| Bishop | 4 diagonal directions | 1…7 (slide) | Stops at blockers |
| Knight | 8 L-shaped offsets | Exactly 1 hop | Ignores blockers |
| Pawn | Forward (and diagonals to capture) | 1 or 2 | Direction depends on color |
Notice: Rook, Bishop and Queen all "slide" in some direction set. They share the same _slide() helper — but each calls it with a different list of directions. That's overriding plus code reuse: different what, shared how.
The Python Implementation
This is the complete engine behind the board below. Read it once, then press Run to execute it right here in your browser.
The Chess Piece Engine
One base class, six subclasses, one shared _slide helper. The main loop asks every piece on the board for its moves — and nothing ever checks a piece's type.
class Piece:
def __init__(self, color, row, col):
self.color = color
self.row = row
self.col = col
def legal_moves(self, board):
raise NotImplementedError("Subclasses must implement legal_moves()")
def symbol(self):
raise NotImplementedError("Subclasses must implement symbol()")
def _inside(self, r, c):
return 0 <= r < 8 and 0 <= c < 8
def _can_land(self, board, r, c):
target = board.get((r, c))
return target is None or target.color != self.color
def _slide(self, board, directions):
moves = []
for dr, dc in directions:
r, c = self.row + dr, self.col + dc
while self._inside(r, c):
target = board.get((r, c))
if target is None:
moves.append((r, c))
else:
if target.color != self.color:
moves.append((r, c))
break
r += dr
c += dc
return moves
DIRS_8 = [(dr, dc) for dr in (-1, 0, 1) for dc in (-1, 0, 1) if (dr, dc) != (0, 0)]
DIRS_STRAIGHT = [(-1, 0), (1, 0), (0, -1), (0, 1)]
DIRS_DIAGONAL = [(-1, -1), (-1, 1), (1, -1), (1, 1)]
class King(Piece):
def legal_moves(self, board):
return [
(self.row + dr, self.col + dc)
for dr, dc in DIRS_8
if self._inside(self.row + dr, self.col + dc)
and self._can_land(board, self.row + dr, self.col + dc)
]
def symbol(self):
return "♔" if self.color == "white" else "♚"
class Queen(Piece):
def legal_moves(self, board):
return self._slide(board, DIRS_8)
def symbol(self):
return "♕" if self.color == "white" else "♛"
class Rook(Piece):
def legal_moves(self, board):
return self._slide(board, DIRS_STRAIGHT)
def symbol(self):
return "♖" if self.color == "white" else "♜"
class Bishop(Piece):
def legal_moves(self, board):
return self._slide(board, DIRS_DIAGONAL)
def symbol(self):
return "♗" if self.color == "white" else "♝"
class Knight(Piece):
OFFSETS = [(-2, -1), (-2, 1), (-1, -2), (-1, 2),
(1, -2), (1, 2), (2, -1), (2, 1)]
def legal_moves(self, board):
return [
(self.row + dr, self.col + dc)
for dr, dc in self.OFFSETS
if self._inside(self.row + dr, self.col + dc)
and self._can_land(board, self.row + dr, self.col + dc)
]
def symbol(self):
return "♘" if self.color == "white" else "♞"
class Pawn(Piece):
def legal_moves(self, board):
moves = []
step = -1 if self.color == "white" else 1
start_row = 6 if self.color == "white" else 1
r = self.row + step
if self._inside(r, self.col) and board.get((r, self.col)) is None:
moves.append((r, self.col))
if self.row == start_row and board.get((r + step, self.col)) is None:
moves.append((r + step, self.col))
for dc in (-1, 1):
r, c = self.row + step, self.col + dc
if self._inside(r, c):
target = board.get((r, c))
if target is not None and target.color != self.color:
moves.append((r, c))
return moves
def symbol(self):
return "♙" if self.color == "white" else "♟"
board = {
(7, 4): King("white", 7, 4),
(7, 3): Queen("white", 7, 3),
(7, 0): Rook("white", 7, 0),
(7, 7): Rook("white", 7, 7),
(7, 2): Bishop("white", 7, 2),
(7, 5): Bishop("white", 7, 5),
(7, 1): Knight("white", 7, 1),
(6, 0): Pawn("white", 6, 0),
(0, 4): King("black", 0, 4),
(0, 3): Queen("black", 0, 3),
(0, 1): Knight("black", 0, 1),
(1, 1): Pawn("black", 1, 1),
}
for pos, piece in board.items():
moves = piece.legal_moves(board)
print(f"{piece.symbol()} {type(piece).__name__:7s} at {pos} "
f"-> {len(moves)} moves")🔍 Deep dive — how one loop calls six different methods
The final loop calls piece.legal_moves(board) for each of the 12 pieces. Watch how Python resolves each call:
| # | What Python does | Result |
|---|---|---|
| 1 | Looks up legal_moves on King | Found — runs King.legal_moves |
| 2 | Looks up legal_moves on Queen | Found — runs Queen.legal_moves |
| 3 | Looks up legal_moves on Rook | Found — runs Rook.legal_moves |
| 4 | … and so on for each piece | Each uses its own rule |
Key insight: the loop never mentions King, Queen or Knight. It only knows that every piece can answer legal_moves(). That's the polymorphic contract working.
📖 Line-by-line walkthrough
Piece.__init__(self, color, row, col)— stores the shared state: color and board position. Every subclass inherits it.legal_moves()andsymbol()raiseNotImplementedError— placeholders. Any subclass that forgets to override them fails loudly when called._inside(r, c)— bounds check. ReturnsTrueif a square is on the 8×8 board._can_land(board, r, c)— a piece can land on an empty square or an enemy's square, but not a friendly one._slide(board, directions)— the shared helper for sliding pieces. For each direction, it walks step by step, collecting empty squares until it hits something. If that something is an enemy, it can be captured; then the walk in that direction stops.DIRS_8,DIRS_STRAIGHT,DIRS_DIAGONAL— direction sets. Rook uses straight, Bishop uses diagonal, Queen uses both.King.legal_moves— a one-step walk in all 8 directions. It can't use_slidedirectly (which slides multiple squares), so it builds the list with a comprehension.Queen.legal_moves— literallyself._slide(board, DIRS_8). Maximum reuse.Rook.legal_moves—self._slide(board, DIRS_STRAIGHT).Bishop.legal_moves—self._slide(board, DIRS_DIAGONAL).Knight.OFFSETS— the eight L-shaped jumps, as a class-level constant.Knight.legal_moves— checks each offset, no sliding. Knights jump over anything, so it never looks at intermediate squares.Pawn.legal_moves— the only piece whose direction depends on color. Forward one; if still on the starting rank, forward two (if both squares are empty); diagonal captures on enemy pieces only.- The
boarddictionary — maps(row, col)to piece. Empty squares simply aren't in the dict. - The final loop — calls
legal_moves()andsymbol()on every piece, plustype(piece).__name__to label the output. One loop, six behaviors.
⚠️ Common mistakes
Using isinstance inside legal_moves. If Queen.legal_moves checks "am I also a rook?" it's collapsing the polymorphism back into the very pattern we escaped.
Letting pawns capture forward. Real chess: pawns capture only diagonally. Our implementation gets that right — a common bug is to allow forward captures.
Sliding through a blocker. In _slide, the walk must break after a capture. Otherwise a bishop can leap over a pawn.
Forgetting the knight jumps. The knight is the only piece that doesn't use _slide, precisely because it jumps over occupied squares.
🧪 Try it yourself
Experiments in the playground below:
- Add a "Princess" (bishop + knight) that merges the two move sets. How many existing classes did you edit?
- Give
Pawnen passant rules. Which other pieces need to know about it? - Add a
Piece.attacks(board)method that returns the squares a piece threatens (which differs fromlegal_movesfor pawns). Use it to check if a king is in check. - Write an
is_checkmate(color, board)function that loops over every enemy piece and asks itslegal_moves(). Notice you never mention a piece type.
Experiment With the Engine
Modify the code above — add a piece, change a rule, test a hypothesis. Press Run to execute it in your browser.
Play With the Pieces
Click a piece to see its legal moves highlighted. Green squares are valid moves; red squares are captures. Hover over a piece to see how many moves it has.
What's happening under the hood: when you click a piece, the board calls piece.legal_moves(board) — the same method from the code above. The board doesn't know whether it's holding a rook or a queen. It just asks.
Quick Knowledge Check
Five questions on the ideas from this project.
Great effort — review any missed questions above.
Explore the Full OOP Series
This project is one of 10 live apps in the Polymorphism guide. Each project shows a different flavor of polymorphism in a working app.
Each shape class overrides draw().
Any class with apply() plugs in.
Money arithmetic with dunder methods.
OperatorsPer-type quantity formatting.
SingledispatchSongs, podcasts and videos share a play loop.
OverridingFake files and real files look the same.
Duck TypingDebits and credits support + and __neg__.
Formats logs per type.
SingledispatchEach piece overrides legal_moves().
Live isinstance with Protocols.
Explore the Full OOP Series
Subscribe to Poetic Bytes
Level up your Python knowledge.
In-depth Python tutorials, AI insights, and software engineering guides — delivered straight to your inbox. No spam, ever.
🔒 Your email is never shared or sold.