Skip to content
FollowtheDamnCode

LLD: Chess

Board, pieces, moves, and turns — polymorphic pieces that know how they move, a game that knows the rules around them, and moves as objects you can undo.

Big idea

Chess is a showcase for polymorphism: every piece answers "where can you move from here?" in its own way, so the game never switches on piece type. Around the pieces sits a Game that enforces turn order and the rules no single piece can know (you can't leave your own king in check), and moves are objects (Command) so history, undo, and notation come for free.

Requirements

Functional

  • 8×8 board, two players, standard starting position.
  • Each piece type has its movement rules; sliding pieces are blocked by others; captures remove the opponent's piece.
  • Players alternate turns; illegal moves are rejected with a reason.
  • A move that leaves your own king in check is illegal; detect check.
  • Move history with undo.

Out of scope (name them): castling, en passant, promotion choice, checkmate/stalemate search, clocks. Each slots in later without restructuring.

Entities

Game (turns, rules, history), Board (8×8 grid, lookups), Piece (abstract: colour, moves(board, square)), King, Queen, Rook, Bishop, Knight, Pawn, Move (from, to, captured piece), Player, Color.

Think of it like

A referee and a team of specialists. Each specialist (piece) knows exactly how they're allowed to move and says "from here I could go to these squares". The referee (game) checks whose turn it is and that nobody's move leaves their own king exposed, and writes every move in the scorebook (history).

Class diagram

Pieces are polymorphic; the game owns the rules

Key flow: validating a move

A move is checked by the piece, then by the game

Code skeleton

Pieces that know their moves, a game that knows the rules
FILES = "abcdefgh"
def sq(name): return FILES.index(name[0]), int(name[1]) - 1
def name(c, r): return f"{FILES[c]}{r + 1}"

class Piece:
    symbol = "?"
    def __init__(self, color): self.color = color
    def moves(self, board, pos): raise NotImplementedError
    def __str__(self): return self.symbol.upper() if self.color == "white" else self.symbol

class SlidingPiece(Piece):
    directions = ()
    def moves(self, board, pos):
        out = []
        for dc, dr in self.directions:
            c, r = pos[0] + dc, pos[1] + dr
            while 0 <= c < 8 and 0 <= r < 8:
                other = board.get((c, r))
                if other is None:
                    out.append((c, r))
                else:
                    if other.color != self.color: out.append((c, r))
                    break                          # blocked
                c, r = c + dc, r + dr
        return out

class Rook(SlidingPiece):   symbol, directions = "r", ((1, 0), (-1, 0), (0, 1), (0, -1))
class Bishop(SlidingPiece): symbol, directions = "b", ((1, 1), (1, -1), (-1, 1), (-1, -1))
class Queen(SlidingPiece):  symbol, directions = "q", Rook.directions + Bishop.directions

class Knight(Piece):
    symbol = "n"
    def moves(self, board, pos):
        jumps = [(1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2)]
        targets = [(pos[0] + a, pos[1] + b) for a, b in jumps]
        return [t for t in targets if 0 <= t[0] < 8 and 0 <= t[1] < 8
                and (board.get(t) is None or board[t].color != self.color)]

class King(Piece):
    symbol = "k"
    def moves(self, board, pos):
        return [(pos[0] + a, pos[1] + b) for a in (-1, 0, 1) for b in (-1, 0, 1) if (a or b)
                and 0 <= pos[0] + a < 8 and 0 <= pos[1] + b < 8
                and (board.get((pos[0] + a, pos[1] + b)) is None or board[(pos[0] + a, pos[1] + b)].color != self.color)]

class Pawn(Piece):
    symbol = "p"
    def moves(self, board, pos):
        step = 1 if self.color == "white" else -1
        c, r = pos
        out = []
        if board.get((c, r + step)) is None:
            out.append((c, r + step))
            start = 1 if self.color == "white" else 6
            if r == start and board.get((c, r + 2 * step)) is None:
                out.append((c, r + 2 * step))
        for dc in (-1, 1):
            t = board.get((c + dc, r + step))
            if t is not None and t.color != self.color:
                out.append((c + dc, r + step))
        return out


class Game:
    def __init__(self, setup):
        self.board = {sq(s): p for s, p in setup.items()}
        self.turn, self.history = "white", []

    def in_check(self, color):
        king = next(p for p, piece in self.board.items() if isinstance(piece, King) and piece.color == color)
        return any(king in piece.moves(self.board, p) for p, piece in list(self.board.items()) if piece.color != color)

    def move(self, src, dst):
        s, d = sq(src), sq(dst)
        piece = self.board.get(s)
        if piece is None or piece.color != self.turn:
            return f"{src}-{dst}: not your piece"
        if d not in piece.moves(self.board, s):
            return f"{src}-{dst}: illegal for {type(piece).__name__}"
        captured = self.board.pop(d, None)               # apply the move (a Move object, in essence)
        self.board[d] = self.board.pop(s)
        if self.in_check(self.turn):                     # rule no single piece knows
            self.board[s] = self.board.pop(d)
            if captured: self.board[d] = captured
            return f"{src}-{dst}: leaves your king in check"
        self.history.append((s, d, captured))
        self.turn = "black" if self.turn == "white" else "white"
        note = " (check)" if self.in_check(self.turn) else ""
        return f"{src}-{dst}{' x' + str(captured) if captured else ''}{note}"

    def undo(self):
        s, d, captured = self.history.pop()
        self.board[s] = self.board.pop(d)
        if captured: self.board[d] = captured
        self.turn = "black" if self.turn == "white" else "white"


game = Game({"e1": King("white"), "d1": Queen("white"), "e2": Pawn("white"), "g1": Knight("white"),
             "e8": King("black"), "e7": Rook("black"), "d7": Pawn("black")})
for src, dst in [("e2", "e4"), ("e7", "e4"), ("g1", "g3"), ("d1", "d2"), ("g1", "e2"),
                 ("d7", "d6"), ("d1", "d6"), ("e8", "e7"), ("e8", "f7")]:
    print(game.move(src, dst))
game.undo()
print("after undo, to move:", game.turn)
output
e2-e4
e7-e4 xP (check)
g1-g3: illegal for Knight
d1-d2: leaves your king in check
g1-e2
d7-d6
d1-d6 xp
e8-e7: leaves your king in check
e8-f7
after undo, to move: black

Run and edit it in the app.

Every moves() method is small and testable alone, and adding a fairy-chess piece is one new class. The "can't expose your king" rule lives in Game, because it needs the whole board.

Watch it

Where does the "a bishop moves diagonally until blocked" rule belong?

Why is "a move may not leave your own king in check" enforced by Game rather than by each piece?

Extensions and trade-offs

  • Special moves — castling, en passant, and promotion need history or state (has the king moved?), so they're added in Game with help from pieces.
  • Checkmate/stalemate — "no legal move exists" = try every piece's moves through the same validation.
  • Moves as objects — a Move class with execute/undo (Command) supports replay, notation export, and networked play.
  • Performance — engines use bitboards; an LLD interview cares about clarity, so say that and move on.
  • Players — human, AI, remote: a Player interface with choose_move(game) (Strategy).

Key terms

  • Sliding piece — A piece (rook, bishop, queen) that moves any distance along a line until blocked.
  • Pseudo-legal move — A move allowed by the piece's pattern, before checking the king's safety.
  • Check — A position where a king is attacked by an opponent's piece.