Chess: Piece Polymorphism and Undo via Command
Each of the six piece types owns its own canMove geometry through a Piece base class, while every Move is a reversible Command whose execute/undo pair — reused to answer "does this leave my own king in check?" — turns self-check validation and undo/redo into the same trick played twice.
11.1Problem Statement
Design a two-player chess game engine: legal move generation for all six piece types, turn management, check / checkmate / stalemate detection, and undo/redo of moves.
Functional requirements. An 8×8 board with King, Queen, Rook, Bishop, Knight, and Pawn, each enforcing its own movement rules. Turn order alternates White then Black, and only legal moves are accepted. The engine detects check, checkmate, and stalemate. Special moves — castling, en passant, pawn promotion — are supported. Every move can be undone and redone.
Non-functional constraints, and they decide the whole design:
- Move rules must be polymorphic per piece — no giant
switchon piece type. - Moves are modeled as Command objects that carry enough state to undo themselves, which is what enables undo/redo and a move-history list for free.
- The board and move-validation logic must be testable in isolation, with no dependency on turns, clocks, or a running game.
The interviewer typically hands you the shape of the public interface up front, deliberately naming the two patterns:
class Piece { // polymorphic move rules
public:
virtual bool canMove(const Board&, const Cell& from, const Cell& to) const = 0;
virtual ~Piece() = default;
protected:
Color color_;
};
class Move { // Command — supports undo
public:
virtual void execute(Board&) = 0;
virtual void undo(Board&) = 0;
virtual ~Move() = default;
};
class Game {
public:
MoveResult playMove(const Cell& from, const Cell& to); // validates, applies, switches turn
void undo();
void redo();
GameStatus status() const; // IN_PROGRESS / CHECK / CHECKMATE / STALEMATE / DRAW
};
canMove) wrapped in a Command pattern (Move::execute/undo). Once every move is honestly reversible, "would this leave my king in check?" and "let the user undo" are the exact same mechanism, invoked twice for two different reasons.What the interviewer is really testing: whether polymorphic piece rules come out clean (each Piece subclass owns its own canMove, nothing switches on type); whether the Command pattern is used correctly for undo/redo and history rather than bolted on after the fact; whether "would this move leave my own king in check?" is handled correctly, since it is the one rule that couples move validation back to game state; and whether special-case moves (castling, en passant, promotion) are handled without polluting the core pipeline with if chains.
Follow-up questions to expect: move history and PGN export with replay; a chess clock with time-out loss; a bot opponent plugged in behind a MoveStrategy interface (random first, minimax later — a Strategy-pattern seam); and networked two-client play, including how you'd serialize board state or stream moves.
11.2How to Approach
Read the constraints like an interviewer wrote them, because they name the design. "Polymorphic per piece, no switch" is the headline: a switch (pieceType) answer fails on sight, so Piece must be abstract with one pure-virtual canMove, and every concrete piece answers only its own geometry. "Moves modeled as Command objects that carry enough state to undo" means undo/redo is not a bolt-on feature added at the end — it forces every move, from day one, to be a reversible object that remembers exactly what it overwrote: a captured piece, a moved-flag, an en-passant victim.
The Piece hierarchy: pure geometry, no rules
Piece::canMove(board, from, to) answers one question only: "could this piece geometrically slide from from to to on this board?" It deliberately does not know about turns, whose move it is, or check. Rooks and Bishops and Queens are "sliders" that share a pathClear helper walking the straight or diagonal line between two squares and rejecting any occupied square in between; Knights are "jumpers" that skip that helper entirely, since nothing blocks an L-shaped hop. The King moves one square in any direction; the Pawn is the one piece whose rule genuinely branches three ways — a quiet single step, an opening two-step from its starting rank, and a diagonal capture — and still needs no switch, because all three branches live inside one subclass's canMove, not spread across a dispatcher.
This pure-geometry answer is usually called pseudo-legal: legal by the piece's own shape, but not yet checked against the one global rule that couples back to game state.
Layering check, checkmate, and stalemate on top of move generation
"Would this move leave my own king in check?" cannot be answered by geometry alone — it requires actually making the move and asking whether any enemy piece now attacks the king's square. That single fact, Board::isAttacked(kingSquare, enemyColor), is itself built from nothing more than a sweep that reuses canMove: a square is attacked if any enemy piece's pseudo-legal geometry reaches it. No piece needs a second, "attacking" rule — the same canMove answers both "can I move here" and "do I threaten this square."
Check, checkmate, and stalemate then fall out of two yes/no facts, recomputed after every move: is the side to move currently in check? and does that side have any legal move at all? In check with no legal move is checkmate. Not in check with no legal move is stalemate. In check with at least one legal move is just check. Finding "any legal move at all" is brute force by design: try every pseudo-legal from → to for every piece of that color, apply it, test the king, and undo — the first one that survives proves a legal move exists.
canMove knowing about check would couple pure geometry to game state — exactly the coupling the problem statement's "testable in isolation" requirement forbids. Push that question one layer up, into Game.Special-casing castling, en passant, and promotion
Naive designs let special moves leak into the core as if (isCastle) … scattered through move generation and application. The fix is the same lever as the piece hierarchy: a special move is just another Move subclass. CastleMove moves both the king and the rook in execute and restores both in undo — the core pipeline in Game::playMove never learns that two pieces moved instead of one; it only ever calls execute/undo through the Move interface. Game is responsible only for recognizing castling intent (a king sliding two squares along its own rank) and checking the castling preconditions — neither king nor rook has moved, the squares between them are empty, and the king is not currently in check — before it builds a CastleMove instead of a plain one.
En passant and promotion follow the identical trick in sol.md's own telling: each would be one more Move subclass capturing whatever extra state its own undo needs (the en-passant victim's square; the pawn's pre-promotion identity). This chapter's synthesized program implements castling in full, since that is the one special move the source walkthrough carries end to end, including the Game-level intent recognition and precondition check. It deliberately does not implement en passant or pawn promotion, and simplifies the castling precondition check by skipping the "king does not pass through an attacked square" rule — the walkthrough names that rule in prose but never codes it, so the honest thing is to leave it as a one-line comment pointing at the extra isAttacked call rather than inventing rules the source never actually specifies.
Move as Command: execute/undo for free undo, redo, and self-check
Move is a two-method interface — execute(Board&) and undo(Board&) — and every concrete move is a small, self-contained record of what it did. A NormalMove stashes the piece that moved, whatever piece (if any) it captured, and whether this was that piece's first move; execute relocates the piece and marks it moved, and undo reverses every one of those three writes exactly. Note that Board never deletes a captured piece on execute — ownership lives in a vector the Board holds independently of the grid, so the grid square simply stops pointing at the captured piece, and undo can point it right back.
Game keeps two stacks of these Commands, undoStack_ and redoStack_. A played move pushes onto the undo stack and clears the redo stack; user-facing undo() pops one, calls its undo, and pushes it onto the redo stack; redo() does the mirror image with execute. None of that is chess-specific — it is the standard Command-pattern history mechanism, and it is also exactly what powers self-check validation: playMove builds the Command, calls execute immediately, checks whether the mover's own king is now attacked, and if so calls undo on the very same object and reports the move illegal. The "try it, test it, roll it back if wrong" idiom is not a separate code path from user-facing undo — it is the same method, called for a different reason.
- Pseudo-legal move
- A move that satisfies a piece's own geometry (
canMove) but has not yet been checked against the one rule that depends on game state: does it leave the mover's own king in check? - Self-check validation
- The rule that a move is only fully legal if, after being applied, it does not leave the mover's own king attacked. Implemented as apply → test → roll back, reusing
Move::undo. - Command pattern
- Encapsulating an action (here, a chess move) as an object with
execute/undo, so the action's history can be stored, replayed, and reversed without the caller knowing what kind of action it was.
11.3Whole Solution
The program below merges every incremental fragment from the walkthrough into one internally consistent file: Piece and its six concrete subclasses, the shared pathClear slider helper, Board with ownership of every piece and a cached king square per color, the Move/NormalMove/CastleMove Command hierarchy, and Game orchestrating turn order, self-check validation, status, and undo/redo. It compiles clean under g++ -std=c++17 -Wall -Wextra and the driver's five printed lines — a legal opening push, an out-of-turn rejection, a second legal move, a capture, and an undo followed by a replay of that same capture — all report exactly what they claim to.
#include <cstdlib>
#include <iostream>
#include <memory>
#include <utility>
#include <vector>
// ---------------------------------------------------------------------------
// Enums: identity, the outcome of a move request, and overall game status.
// ---------------------------------------------------------------------------
enum class Color { WHITE, BLACK };
enum class GameStatus { IN_PROGRESS, CHECK, CHECKMATE, STALEMATE, DRAW };
enum class MoveResult { OK, ILLEGAL, NOT_YOUR_TURN, GAME_OVER };
class Piece; // Cell needs a raw pointer to it
class Board; // Piece::canMove takes a Board by const reference
// One square of the 8x8 grid. Board owns every Piece's lifetime; a Cell only
// borrows a raw, non-owning pointer to whichever Piece (if any) stands here.
struct Cell {
int row = 0, col = 0;
Piece* piece = nullptr;
bool isEmpty() const { return piece == nullptr; }
};
// ---------------------------------------------------------------------------
// Piece: the polymorphic move-rule hierarchy. canMove is pure geometry only —
// it knows nothing about turns or check, which is exactly what keeps it
// testable in isolation with nothing but a bare Board.
// ---------------------------------------------------------------------------
class Piece {
public:
explicit Piece(Color c) : color_(c) {}
virtual ~Piece() = default;
// "Could I legally slide from -> to on THIS board?" Pure geometry only.
virtual bool canMove(const Board&, const Cell& from, const Cell& to) const = 0;
virtual bool isKing() const { return false; }
virtual char symbol() const = 0; // 'K','Q','R','B','N','P' -- handy for debug/PGN
Color color() const { return color_; }
bool hasMoved() const { return moved_; }
void setMoved(bool m) { moved_ = m; }
protected:
// Shared helper: you can never land on your own piece.
bool destOk(const Cell& to) const {
return to.isEmpty() || to.piece->color() != color_;
}
Color color_;
bool moved_ = false; // needed for castling rights and the pawn's first 2-step
};
// ---------------------------------------------------------------------------
// Board: the 8x8 grid plus geometry questions (isAttacked, pieceAt, ...).
// Owns every Piece via pieces_; grid_ only holds non-owning raw pointers, so a
// captured piece that leaves the grid stays alive until an undo puts it back.
// ---------------------------------------------------------------------------
class Board {
public:
Board() {
grid_.assign(8, std::vector<Cell>(8));
initCoords();
initializeStandardPosition();
}
bool inBounds(int r, int c) const { return r >= 0 && r < 8 && c >= 0 && c < 8; }
Cell& at(int r, int c) { return grid_[r][c]; }
const Cell& at(int r, int c) const { return grid_[r][c]; }
// Raw board mutation used by Move::execute/undo. No rule-checking here --
// legality is decided before a Move is ever built.
void setPiece(int r, int c, Piece* p) {
grid_[r][c].piece = p;
if (p && p->isKing()) kingCell_[colorIdx(p->color())] = &grid_[r][c];
}
Piece* pieceAt(int r, int c) const { return grid_[r][c].piece; }
const Cell& kingCell(Color c) const { return *kingCell_[colorIdx(c)]; }
// Is `target` attacked by ANY piece of `byColor`? Pure geometry sweep --
// reuses canMove, so "check" needs no rules of its own.
bool isAttacked(const Cell& target, Color byColor) const {
for (int r = 0; r < 8; ++r)
for (int c = 0; c < 8; ++c) {
Piece* p = grid_[r][c].piece;
if (p && p->color() == byColor && p->canMove(*this, grid_[r][c], target))
return true;
}
return false;
}
private:
static int colorIdx(Color c) { return c == Color::WHITE ? 0 : 1; }
void initCoords() {
for (int r = 0; r < 8; ++r)
for (int c = 0; c < 8; ++c) { grid_[r][c].row = r; grid_[r][c].col = c; }
}
void initializeStandardPosition(); // defined below, once every Piece subclass exists
std::vector<std::vector<Cell>> grid_;
Cell* kingCell_[2] = {nullptr, nullptr};
std::vector<std::unique_ptr<Piece>> pieces_; // ownership lives here, not in grid_
};
// Walks the straight or diagonal line strictly between from and to (both
// endpoints excluded) and confirms every square on it is empty. Sliding
// pieces (Rook, Bishop, Queen) call this; jumpers (Knight) never do.
bool pathClear(const Board& b, const Cell& from, const Cell& to) {
int dr = (to.row > from.row) - (to.row < from.row); // -1, 0, or +1
int dc = (to.col > from.col) - (to.col < from.col);
int r = from.row + dr, c = from.col + dc;
while (r != to.row || c != to.col) {
if (!b.at(r, c).isEmpty()) return false;
r += dr; c += dc;
}
return true;
}
// ---------------------------------------------------------------------------
// Concrete pieces. Each owns only its own geometry -- adding a new piece
// means adding a subclass, never touching a switch statement.
// ---------------------------------------------------------------------------
class Rook : public Piece {
public:
using Piece::Piece;
char symbol() const override { return 'R'; }
bool canMove(const Board& b, const Cell& from, const Cell& to) const override {
if (from.row != to.row && from.col != to.col) return false; // straight lines only
if (!destOk(to)) return false;
return pathClear(b, from, to);
}
};
class Knight : public Piece {
public:
using Piece::Piece;
char symbol() const override { return 'N'; }
bool canMove(const Board&, const Cell& from, const Cell& to) const override {
int dr = std::abs(from.row - to.row), dc = std::abs(from.col - to.col);
return ((dr == 2 && dc == 1) || (dr == 1 && dc == 2)) && destOk(to); // L-shape, jumps
}
};
class Bishop : public Piece {
public:
using Piece::Piece;
char symbol() const override { return 'B'; }
bool canMove(const Board& b, const Cell& from, const Cell& to) const override {
if (std::abs(from.row - to.row) != std::abs(from.col - to.col)) return false;
if (!destOk(to)) return false;
return pathClear(b, from, to);
}
};
class Queen : public Piece {
public:
using Piece::Piece;
char symbol() const override { return 'Q'; }
bool canMove(const Board& b, const Cell& from, const Cell& to) const override {
bool straight = (from.row == to.row || from.col == to.col);
bool diagonal = std::abs(from.row - to.row) == std::abs(from.col - to.col);
if (!straight && !diagonal) return false;
if (!destOk(to)) return false;
return pathClear(b, from, to);
}
};
class King : public Piece {
public:
using Piece::Piece;
bool isKing() const override { return true; }
char symbol() const override { return 'K'; }
bool canMove(const Board&, const Cell& from, const Cell& to) const override {
int dr = std::abs(from.row - to.row), dc = std::abs(from.col - to.col);
// One step in any direction. Castling is NOT modeled here -- it is a
// two-square king move recognized and built by Game, one layer up.
return dr <= 1 && dc <= 1 && (dr + dc > 0) && destOk(to);
}
};
class Pawn : public Piece {
public:
using Piece::Piece;
char symbol() const override { return 'P'; }
bool canMove(const Board& b, const Cell& from, const Cell& to) const override {
int dir = (color_ == Color::WHITE) ? -1 : 1; // White advances toward row 0
int startRow = (color_ == Color::WHITE) ? 6 : 1;
int dr = to.row - from.row, dc = to.col - from.col;
if (dc == 0 && dr == dir && to.isEmpty()) return true; // one square
if (dc == 0 && dr == 2 * dir && from.row == startRow && // opening 2-step
to.isEmpty() && b.at(from.row + dir, from.col).isEmpty())
return true;
if (std::abs(dc) == 1 && dr == dir && // diagonal capture
!to.isEmpty() && to.piece->color() != color_)
return true;
// En passant and promotion are deliberately not modeled here -- see
// "How to Approach" for why they are new Move subclasses, not new
// branches inside this method.
return false;
}
};
// Board's standard setup needs concrete pieces, so it is defined only now
// that Rook..Pawn all exist. Declared inside the class above; wired here.
void Board::initializeStandardPosition() {
auto place = [&](int r, int c, std::unique_ptr<Piece> p) {
Piece* raw = p.get();
pieces_.push_back(std::move(p));
setPiece(r, c, raw);
};
auto backRank = [&](int r, Color color) {
place(r, 0, std::make_unique<Rook>(color));
place(r, 1, std::make_unique<Knight>(color));
place(r, 2, std::make_unique<Bishop>(color));
place(r, 3, std::make_unique<Queen>(color));
place(r, 4, std::make_unique<King>(color));
place(r, 5, std::make_unique<Bishop>(color));
place(r, 6, std::make_unique<Knight>(color));
place(r, 7, std::make_unique<Rook>(color));
};
backRank(0, Color::BLACK); // rank 8
backRank(7, Color::WHITE); // rank 1
for (int c = 0; c < 8; ++c) {
place(1, c, std::make_unique<Pawn>(Color::BLACK)); // rank 7
place(6, c, std::make_unique<Pawn>(Color::WHITE)); // rank 2
}
}
// ---------------------------------------------------------------------------
// Move: the Command hierarchy. Every move remembers exactly what it
// overwrote, which is what makes undo lossless and redo trivial.
// ---------------------------------------------------------------------------
class Move {
public:
virtual ~Move() = default;
virtual void execute(Board&) = 0;
virtual void undo(Board&) = 0;
};
class NormalMove : public Move {
public:
NormalMove(Cell from, Cell to) : from_(from), to_(to) {}
void execute(Board& b) override {
moved_ = b.pieceAt(from_.row, from_.col);
captured_ = b.pieceAt(to_.row, to_.col); // may be nullptr; remembered for undo
wasFirstMove_ = !moved_->hasMoved();
b.setPiece(to_.row, to_.col, moved_); // move the piece
b.setPiece(from_.row, from_.col, nullptr); // vacate the origin
moved_->setMoved(true);
}
void undo(Board& b) override {
b.setPiece(from_.row, from_.col, moved_); // put it back
b.setPiece(to_.row, to_.col, captured_); // restore whatever was captured (or null)
if (wasFirstMove_) moved_->setMoved(false);
}
private:
Cell from_, to_;
Piece* moved_ = nullptr;
Piece* captured_ = nullptr; // THE crucial bit of state for undo
bool wasFirstMove_ = false; // restore hasMoved() exactly (matters for castling)
};
// Castling: not a special-cased `if` sprinkled through the core, just another
// Move subclass that moves two pieces and restores both on undo.
class CastleMove : public Move {
public:
CastleMove(Cell kingFrom, Cell kingTo, Cell rookFrom, Cell rookTo)
: kf_(kingFrom), kt_(kingTo), rf_(rookFrom), rt_(rookTo) {}
void execute(Board& b) override {
king_ = b.pieceAt(kf_.row, kf_.col);
rook_ = b.pieceAt(rf_.row, rf_.col);
b.setPiece(kt_.row, kt_.col, king_); b.setPiece(kf_.row, kf_.col, nullptr);
b.setPiece(rt_.row, rt_.col, rook_); b.setPiece(rf_.row, rf_.col, nullptr);
king_->setMoved(true);
rook_->setMoved(true);
}
void undo(Board& b) override {
b.setPiece(kf_.row, kf_.col, king_); b.setPiece(kt_.row, kt_.col, nullptr);
b.setPiece(rf_.row, rf_.col, rook_); b.setPiece(rt_.row, rt_.col, nullptr);
king_->setMoved(false);
rook_->setMoved(false); // castling requires neither piece had moved before
}
private:
Cell kf_, kt_, rf_, rt_;
Piece* king_ = nullptr;
Piece* rook_ = nullptr;
};
// ---------------------------------------------------------------------------
// Game: the rule engine. playMove runs a fixed pipeline -- geometry check,
// try-as-Command, self-check test, commit-or-rollback, switch turn, then
// recompute status by asking "am I in check?" and "do I have any legal move?"
// ---------------------------------------------------------------------------
class Game {
public:
MoveResult playMove(const Cell& from, const Cell& to) {
if (status_ == GameStatus::CHECKMATE || status_ == GameStatus::STALEMATE)
return MoveResult::GAME_OVER;
Piece* p = board_.pieceAt(from.row, from.col);
if (!p || p->color() != turn_) return MoveResult::NOT_YOUR_TURN;
std::unique_ptr<Move> move = buildMove(from, to, p);
if (!move) return MoveResult::ILLEGAL;
// Try it. Self-check validation is just apply-test-rollback, reusing
// the exact same undo() a user-facing Undo button calls.
move->execute(board_);
if (board_.isAttacked(board_.kingCell(turn_), opponent(turn_))) {
move->undo(board_);
return MoveResult::ILLEGAL;
}
undoStack_.push_back(std::move(move));
redoStack_.clear();
turn_ = opponent(turn_);
recomputeStatus();
return MoveResult::OK;
}
void undo() {
if (undoStack_.empty()) return;
auto m = std::move(undoStack_.back()); undoStack_.pop_back();
m->undo(board_);
redoStack_.push_back(std::move(m));
turn_ = opponent(turn_);
recomputeStatus();
}
void redo() {
if (redoStack_.empty()) return;
auto m = std::move(redoStack_.back()); redoStack_.pop_back();
m->execute(board_);
undoStack_.push_back(std::move(m));
turn_ = opponent(turn_);
recomputeStatus();
}
GameStatus status() const { return status_; }
private:
static Color opponent(Color c) { return c == Color::WHITE ? Color::BLACK : Color::WHITE; }
// Decide which kind of Move (if any) from->to represents. Castling intent
// (king sliding two squares along its rank) is recognized here, one layer
// above Piece::canMove, so the piece hierarchy never learns about it.
std::unique_ptr<Move> buildMove(const Cell& from, const Cell& to, Piece* p) const {
if (p->isKing() && from.row == to.row && std::abs(to.col - from.col) == 2) {
if (!canCastle(from, to)) return nullptr;
int rookFromCol = (to.col > from.col) ? 7 : 0;
int rookToCol = (to.col > from.col) ? to.col - 1 : to.col + 1;
return std::make_unique<CastleMove>(
board_.at(from.row, from.col), board_.at(to.row, to.col),
board_.at(from.row, rookFromCol), board_.at(from.row, rookToCol));
}
if (!p->canMove(board_, board_.at(from.row, from.col), board_.at(to.row, to.col)))
return nullptr;
return std::make_unique<NormalMove>(from, to);
}
// Simplified castling pre-check: neither king nor rook has moved, and the
// squares between them are empty. A fully rigorous engine also forbids
// the king passing through an attacked square; sol.md names that rule in
// prose but does not code it, so it is left as the natural next line here
// (one more board_.isAttacked call per transit square).
bool canCastle(const Cell& from, const Cell& to) const {
Piece* king = board_.pieceAt(from.row, from.col);
if (!king || king->hasMoved()) return false;
int rookCol = (to.col > from.col) ? 7 : 0;
Piece* rook = board_.pieceAt(from.row, rookCol);
if (!rook || rook->hasMoved()) return false;
int step = (rookCol > from.col) ? 1 : -1;
for (int c = from.col + step; c != rookCol; c += step)
if (!board_.at(from.row, c).isEmpty()) return false;
return !board_.isAttacked(from, opponent(turn_)); // can't castle out of check
}
// Checkmate = in check AND no legal move escapes it.
// Stalemate = not in check AND no legal move at all.
void recomputeStatus() {
bool inCheck = board_.isAttacked(board_.kingCell(turn_), opponent(turn_));
bool anyLegal = hasAnyLegalMove(turn_);
if (inCheck && !anyLegal) status_ = GameStatus::CHECKMATE;
else if (!inCheck && !anyLegal) status_ = GameStatus::STALEMATE;
else if (inCheck) status_ = GameStatus::CHECK;
else status_ = GameStatus::IN_PROGRESS;
}
// Brute-force: every from->to for `side`'s pieces, try it, test the king,
// undo. The same apply-test-rollback trick used inside playMove.
bool hasAnyLegalMove(Color side) {
for (int r = 0; r < 8; ++r) {
for (int c = 0; c < 8; ++c) {
Piece* p = board_.pieceAt(r, c);
if (!p || p->color() != side) continue;
for (int tr = 0; tr < 8; ++tr) {
for (int tc = 0; tc < 8; ++tc) {
if (tr == r && tc == c) continue;
const Cell& from = board_.at(r, c);
const Cell& to = board_.at(tr, tc);
if (!p->canMove(board_, from, to)) continue;
NormalMove trial(from, to);
trial.execute(board_);
bool stillInCheck =
board_.isAttacked(board_.kingCell(side), opponent(side));
trial.undo(board_);
if (!stillInCheck) return true;
}
}
}
}
return false;
}
Board board_; // constructs to the standard starting position
Color turn_ = Color::WHITE; // White moves first
GameStatus status_ = GameStatus::IN_PROGRESS;
std::vector<std::unique_ptr<Move>> undoStack_, redoStack_;
};
// ---------------------------------------------------------------------------
// Driver: one legal move, one illegal (out-of-turn) rejection, a capture, and
// an undo/replay -- enough to exercise all three pillars without playing out
// a full game.
// ---------------------------------------------------------------------------
int main() {
Game game; // standard 8x8 starting position, White to move
auto r1 = game.playMove({6, 4}, {4, 4}); // e2 -> e4 (White pawn, legal)
std::cout << "e4: " << (r1 == MoveResult::OK ? "OK" : "rejected") << "\n";
auto r2 = game.playMove({6, 3}, {4, 3}); // White piece again, but it's Black's turn
std::cout << "out-of-turn: "
<< (r2 == MoveResult::NOT_YOUR_TURN ? "rejected (as expected)" : "OK") << "\n";
auto r3 = game.playMove({1, 3}, {3, 3}); // d7 -> d5, Black pawn
std::cout << "d5: " << (r3 == MoveResult::OK ? "OK" : "rejected") << "\n";
auto r4 = game.playMove({4, 4}, {3, 3}); // White pawn captures on d5 (exd5)
std::cout << "exd5 (capture): " << (r4 == MoveResult::OK ? "OK" : "rejected") << "\n";
game.undo(); // undo the capture
auto r5 = game.playMove({4, 4}, {3, 3}); // legal again after undo; same capture replayed
std::cout << "replay after undo: " << (r5 == MoveResult::OK ? "OK" : "rejected") << "\n";
std::cout << "status code: " << static_cast<int>(game.status()) << "\n";
return 0;
}
Running it prints five confirmations followed by a status code of 0 (IN_PROGRESS): e4: OK, out-of-turn: rejected (as expected), d5: OK, exd5 (capture): OK, replay after undo: OK.
How would you add move history and PGN export without touching Piece or Move?
The undoStack_ already is the history — it's an ordered list of every Command actually played. Give each Move a notation() method; it already has everything it needs (the from/to cells and, via the moved piece, its symbol()) to render itself as algebraic notation. Walking the stack front-to-back emits a PGN string. Replay is just clearing the board and calling execute on the list in order. None of this touches Piece's geometry or changes what a Move subclass has to store — it's a new method on an interface that already carries all the needed state.
Where would a chess clock and time-out loss plug in?
Orthogonal to move legality, so it lives entirely in Game, not in Piece or Move. Add a per-color Clock member; playMove debits the mover's clock at the start (or commit) of the call, and a flag-fall sets status_ to a loss for whoever's clock hit zero. The piece and move hierarchies are completely untouched — timing is a concern one layer above them, the same way turn order already is.
How do you add a bot opponent without hard-wiring an algorithm into Game?
Name the seam explicitly: a MoveStrategy interface with one method, chooseMove(const Game&, Color side), returning the {from, to} it picks. RandomStrategy and MinimaxStrategy both implement it; the driver (or Game itself) depends on the interface, never on a specific algorithm. Swapping random play for minimax becomes a one-class substitution — textbook Strategy pattern, and precisely the "pattern focus" the problem statement names alongside Command and polymorphism.