Connect Four
Understanding the Problem​
Connect Four is a two-player connection game where the players take turns placing their pieces in a 7x6 grid. The first player to connect four of their pieces in a row, column, or diagonal wins.
Requirements​
As the interview begins, we'll likely be greeted by a simple prompt to set the stage for the architecture we need to design.
Before jumping into class design, you should ask questions of your interviewer. The goal here is to turn that vague prompt into a concrete specification - something you can actually build against.
The goal is to surface ambiguity early and get to a concrete spec. This mirrors the same process we use when clarifying requirements on a real project.
A reliable way to structure your questions is to cover four areas: what the core actions are, how errors should be handled, what the boundaries of the system are, and whether we need to plan for future extensions.
Here's how a conversation between you and the interviewer might go:
Good. You've confirmed the main action. Now think about how the game ends.
Now you know the win and draw conditions. Next, think about what happens when things go wrong.
Now you're covering error handling. Next, figure out the scope.
That last question matters more than you might think. If you need UI support, you'll want methods like getBoardState() or getValidMoves() so something can render the board. If it's backend-only, you can keep the API minimal and focused purely on game rules.
Finally, check if there are any future features to plan for.
Perfect. You've now clarified scope and ruled out unnecessary complexity.
Final Requirements​
After that back-and-forth, you can write down the final requirements, as well as any learning about what is out of scope, on the whiteboard.
Requirements:
1. Two players take turns dropping discs into a 7-column, 6-row board
2. A disc falls to the lowest available row in the chosen column
3. The game ends when:
- A player gets four discs in a row (vertical, horizontal, or diagonal). They win.
- The board is full. It's a draw.
4. Invalid moves should be rejected clearly:
- Dropping in a full column.
- Moving out of turn.
- Moving after the game is over.
Out of scope:
- UI support
- Concurrent games
- Move history
- Undo
- Board size configuration
Core Entities and Relationships​
With a clear set of requirements in hand, the next step is figuring out what objects we need and how they interact.
Start by asking yourself: what are the main "things" in this problem? Look for nouns in your requirements and think about what responsibilities each one should have. In Connect Four, a few jump out immediately: the game itself, the board where pieces are placed, and the players making moves.
A common mistake is putting everything in one giant class or splitting things unnecessarily. Good design means each class has a single, clear job. The Board manages grid state and placement rules. The Game orchestrates turns and win checking. Players are just data—names and which color they're playing.
For Connect Four, here's what makes sense:
Player - Represents a person in the game. Simple data holder with a name and disc color. No game logic here.
Board - The 7x6 grid where discs live. Owns the grid state and handles disc placement. Knows how to check if a column is full, where a disc should fall, and whether four discs are connected. Doesn't care about whose turn it is or who's winning.
Game - The orchestrator. Holds the Board, tracks which Player's turn it is, manages game state (in progress, won, draw), and enforces turn-based rules. When a player makes a move, Game validates it, tells Board to place the disc, checks if that move won, and switches turns.
Class Design​
Now that we've identified the three core entities, the next step is defining their interfaces. This means deciding what data each class holds and what methods it exposes to the outside world.
I recommend starting with a top-down approach. Begin by designing the Game class first since it's the orchestrator and primary entry point. Once we've defined Game's interface, work your way down to Board and Player. This keeps us focused on the public API instead of getting lost in implementation details.
For each entity, we'll use our requirements to derive both the state and the behavior (methods) of the class. Let's start with Game.
Game​
The Game class is the orchestration layer. External code should interact with the game only through this class: creating a new game, asking whose turn it is, making moves, and checking whether the game is over.
You can derive almost everything about Game directly from the final set of requirements. During the interview, revisit the requirements and ask: "What does the game need to remember to enforce this?". This is how you'll derive the state of the Game class.
This leads to the following state:
class Game:
- board: Board
- player1: Player
- player2: Player
- currentPlayer: Player
- state: GameState // IN_PROGRESS, WON, DRAW
- winner: Player? // null if no winner yet or draw
A couple of small but important choices here:
GameStateis an enum, not a string or a boolean pair likeisOver/isDraw. That prevents invalid combinations and makes adding new states (likePAUSED) trivial.winneris nullable. In a draw, there simply is no winner, which is clearer than overloading a special “NONE” value.
Whenever we see ourselves juggling two or three booleans like isOver , hasWinner , isDraw , it's almost always better to introduce a single enum. Collapsing the state space this way keeps the design clean and avoids boolean tangles.
Next, look at the actions the outside world needs to perform. Every method on Game should correspond to a concrete need in the problem statement.
While designing, we may discover methods we need that aren't explicitly in your requirements. That's normal. For example, we might realize we need getCurrentPlayer() so callers can display whose turn it is. Feel free to go back and add it. This iterative refinement is expected and shows good design thinking.
Adding the methods to the Game class, we get:
class Game:
- board: Board
- player1: Player
- player2: Player
- currentPlayer: Player
- state: GameState // IN_PROGRESS, WON, DRAW
- winner: Player? // null if no winner yet or draw
+ new Game(player1: Player, player2: Player) -> Game
+ makeMove(player: Player, column: int) -> bool
+ getCurrentPlayer() -> Player
+ getGameState() -> GameState
+ getWinner() -> Player?
+ getBoard() -> Board
The constructor initializes the game state:
Game(Player player1, Player player2) {
this.board = new Board()
this.player1 = player1
this.player2 = player2
this.currentPlayer = player1 // player1 goes first
this.state = IN_PROGRESS
this.winner = null
}
makeMove is the only method that mutates game state. Everything else is read-only. getCurrentPlayer lets UI layers show "Player X's turn" without duplicating turn logic, while getGameState and getWinner let callers check the outcome without digging into internals.
Player​
Player represents one participant in the game. From the requirements, the system only needs two things: a way to distinguish the players and a way to know which disc color each one uses.
You can derive the state directly from the problem:
This leads to a minimal class:
class Player:
- name: string
- color: DiscColor // RED or YELLOW
Why these fields:
namelets the Game determine whose turn it is and validate that the caller ofmakeMovematches the expected player. This could be a display name or some stable ID—we just need something Game can compare.colorlinks placed discs to the correct owner inside the Board grid.
The interface is correspondingly small:
class Player:
- name: string
- color: DiscColor
+ new Player(name: string, color: DiscColor) -> Player
+ getName() -> string
+ getColor() -> DiscColor
The color enum:
enum DiscColor:
RED
YELLOW
Player stays deliberately simple. All game flow, move validation, and win logic belong elsewhere.
Board​
Board owns the grid. It knows where discs are, whether a column has space, how discs "fall," and whether a given move creates four in a row.
You can derive its state straight from the requirements:
That leads to something like:
class Board:
- rows: int // 6
- cols: int // 7
- grid: DiscColor?[rows][cols] // null if empty; otherwise the disc color
We store DiscColor in the grid rather than Player to keep the board separately testable. You could store Player instead if you prefer—both work as long as you're consistent. But, from a testability perspective, it's better to store DiscColor since it's a simpler type and doesn't require you to mock a Player object.
From the outside, Board needs to support a small set of actions:
That gives you this interface:
class Board:
- rows: int = 6
- cols: int = 7
- grid: DiscColor?[rows][cols]
+ new Board() -> Board
+ getRows() -> int
+ getCols() -> int
+ canPlace(column: int) -> bool
+ placeDisc(column: int, color: DiscColor) -> int // returns row where disc lands
+ isFull() -> bool
+ checkWin(row: int, column: int, color: DiscColor) -> bool
+ getCell(row: int, column: int) -> DiscColor?
Board encapsulates all the grid math and win detection. Game doesn't know how to scan four in a row; it just asks Board and updates its own state accordingly.
Implementation​
With the core classes defined, the next step is walking through how each method actually behaves. Different companies treat this step differently—some want pseudo-code, some want real code, some just want us to talk through it. After we present your class designs, ask your interviewer what level of detail they want.
For each method, follow a consistent pattern:
- Define the core logic - The happy path that fulfills the requirement.
- Consider edge cases - What can go wrong? Invalid inputs, illegal states, boundary conditions.
This structure is natural because you first understand what the method should do, then think about what could break it. Interviewers notice when you systematically identify edge cases—it signals you think about production code, not just the happy path.
If interviewers want code, they'll typically ask for the most interesting methods. In our case, these are:
makeMoveto show turn enforcement and game flowplaceDiscto show how discs fall in a columncheckWinto show directional scanning
When writing actual code, aim for clarity over cleverness. Avoid premature optimization.
Game​
For Game , and frankly, the entire design, the core method is makeMove it encapsulates the entire game flow and is the most interesting method to implement.
Core logic:
- Place the disc via
board.placeDisc(column, player.getColor())-> returns row - Check for win via
board.checkWin(row, column, player.getColor()) - If no win, check for draw via
board.isFull() - Switch turn if game is still in progress
- Return true
Edge cases (reject before touching state):
- Game is already over (state is WON or DRAW)
- Wrong player's turn
- Column index out of bounds
- Column is full
We can turn this into a simple pseudo-code implementation:
bool makeMove(Player player, int column) {
if (state != IN_PROGRESS) return false
if (player != currentPlayer) return false
if (column < 0 || column >= board.getCols()) return false
if (!board.canPlace(column)) return false
int row = board.placeDisc(column, player.getColor())
if (board.checkWin(row, column, player.getColor())) {
state = WON
winner = player
} else if (board.isFull()) {
state = DRAW
} else {
currentPlayer = (player == player1) ? player2 : player1 // switch turn
}
return true
}
We'll want to talk through each decision and weigh your choice against alternatives, weighing the trade-offs as we go. Two common alternatives worth mentioning:
- Have
makeMovethrow exceptions instead of returning false on invalid moves. This can be fine in some languages, but in an interview, a simple boolean result often keeps things clearer. If unsure, ask your interviewer. - Have
makeMoveimplicitly usecurrentPlayerwithout taking player as an argument. This is simpler if the only code calling makeMove is your own. Taking player explicitly can be useful if we imagine a networked setting where moves arrive tagged with a player. Either choice is reasonable as long as we explain it.
Player​
Player has no interesting implementation—just getters for name and color . Skip this unless the interviewer explicitly asks for it.
Board​
For the Board class, the most interesting methods are placeDisc and checkWin so we'll walk through each of them in detail.
Starting with placeDisc , you can derive the core logic and edge cases as follows:
Core logic:
- Find the lowest empty row in that column—start from
row = rows - 1and move upward until you findgrid[row][column] == null - Place the disc—set
grid[row][column] = color - Return the row where the disc landed
Returning the row lets Game pass (row, column, color) into checkWin without re-scanning. You can mention an optimization: keep a heights[cols] array that tracks the next free row per column. For a 7x6 board, the simple loop is fine.
Edge cases:
- Column index out of bounds -> return error or -1
- Column is full -> return error or -1
If Game already calls canPlace() before placeDisc() , you can skip these checks in placeDisc and document that assumption. Either approach is fine as long as you're explicit about it.
In pseudo-code, this looks like:
int placeDisc(int column, DiscColor color) {
if (column < 0 || column >= cols) return -1
if (!canPlace(column)) return -1
for (int row = rows - 1; row >= 0; row--) {
if (grid[row][column] == null) {
grid[row][column] = color
return row
}
}
return -1
}
Moving onto the checkWin method, you can derive the core logic and edge cases as follows:
Core logic:
- Define the four directions: horizontal
(0, 1), vertical(1, 0), diagonal down-right(1, 1), diagonal up-right(-1, 1) - For each direction, count contiguous discs in both directions from
(row, column) - If any direction reaches 4 or more, return
true
Edge cases:
- Row or column out of bounds → return false
- Cell at (row, column) doesn't match the given color → return false
Here's a pseudo-code sketch:
bool checkWin(int row, int col, DiscColor color) {
int[][] directions = {{0,1}, {1,0}, {1,1}, {-1,1}}
for (dir in directions) {
int count = 1
count += countInDirection(row, col, dir[0], dir[1], color)
count += countInDirection(row, col, -dir[0], -dir[1], color)
if (count >= 4) return true
}
return false
}
int countInDirection(int row, int col, int dr, int dc, DiscColor color) {
int count = 0
int r = row + dr, c = col + dc
while (inBounds(r, c) && grid[r][c] == color) {
count++
r += dr
c += dc
}
return count
}
This pattern is easy to talk through and the interviewer can quickly see you're not missing diagonals.
I've seen candidates try to over-engineer this by creating a WinChecker interface with separate HorizontalWinChecker , VerticalWinChecker , and DiagonalWinChecker implementations. This is unnecessary complexity. The direction vector approach handles all four cases with identical logic—just different (dr, dc) values. Don't force design patterns where they don't add value. The Strategy pattern makes sense when win conditions have genuinely different logic, but here it would triple your code for no benefit.
The remaining helper methods are straightforward but worth showing for completeness:
bool canPlace(int column) {
if (column < 0 || column >= cols) return false
return grid[0][column] == null // top row empty means column has space
}
bool isFull() {
for (int c = 0; c < cols; c++) {
if (canPlace(c)) return false
}
return true
}
bool inBounds(int row, int col) {
return row >= 0 && row < rows && col >= 0 && col < cols
}
Note that placeDisc assumes canPlace() was already called, so it will never fail to find an empty row. If you want to be defensive, you could check row == -1 in makeMove after calling placeDisc , but it's not strictly necessary given the precondition.
from enum import Enum
from typing import Optional
class GameState(Enum):
IN_PROGRESS = "IN_PROGRESS"
WON = "WON"
DRAW = "DRAW"
class Game:
def __init__(self, player1, player2):
self.board = Board()
self.player1 = player1
self.player2 = player2
self.current_player = player1
self.state = GameState.IN_PROGRESS
self.winner: Optional["Player"] = None
def make_move(self, player, column: int) -> bool:
if self.state is not GameState.IN_PROGRESS:
return False
if player is not self.current_player:
return False
if column < 0 or column >= self.board.get_cols():
return False
if not self.board.can_place(column):
return False
row = self.board.place_disc(column, player.color)
if self.board.check_win(row, column, player.color):
self.state = GameState.WON
self.winner = player
elif self.board.is_full():
self.state = GameState.DRAW
else:
self.current_player = self.player2 if self.current_player is self.player1 else self.player1
return True
def get_current_player(self) -> Player:
return self.current_player
def get_game_state(self) -> GameState:
return self.state
def get_winner(self) -> Optional[Player]:
return self.winner
def get_board(self) -> Board:
return self.board
Verification​
Let's trace through a quick game to verify the win detection and state transitions work. Player1 (RED) builds a horizontal line at row 5.
Initial: empty board, currentPlayer = player1, state = IN_PROGRESS
Move 1: player1 → column 0
placeDisc(0, RED) → row 5 (bottom)
checkWin(5, 0, RED)? No
currentPlayer = player2
Move 2: player1 → column 1
placeDisc(1, RED) → row 5
checkWin(5, 1, RED)? No
currentPlayer = player2
Move 3: player1 → column 2
placeDisc(2, RED) → row 5
checkWin(5, 2, RED)? No
currentPlayer = player2
Move 4: player1 → column 3
placeDisc(3, RED) → row 5
checkWin(5, 3, RED)?
Check horizontal: (5,0), (5,1), (5,2), (5,3) all RED → count = 4
Returns true!
state = WON, winner = player1
No turn switch (game over)
Move 5: player2 tries column 4
state != IN_PROGRESS → returns false immediately
This verifies disc placement, horizontal win detection, state transitions, and move rejection after game ends.
It's important to verify, but you don't need to write out all the states like this; it might take too much time. Check with your interviewer, but just verbally going over some test cases is usually enough. Remember, the goal is to catch logical errors before your interviewer finds them.
Extensibility​
If time allows, interviewers will sometimes add small twists to test whether your design can evolve cleanly. You typically won't need to fully implement these changes—just explain how your classes would adapt. The depth and quantity of the extensibility follow-ups correlate with the candidate's target level (e.g., junior, mid-level, senior). Junior candidates often won't get any, mid-level may get one or two, and senior candidates may be asked to go into more depth.
If you're a junior engineer, feel free to skip this section and stop reading here! Only carry on if you're curious about the more advanced concepts.
Below are the most common ones for Connect Four, with more detail than you'd need in an actual interview.
1. "How would you support different board sizes?"​
Right now the requirement says the board is always 7x6, so you hardcode that into Board . If an interviewer asks about configurable dimensions, the goal is to show that your design already has a natural place to plug this in.
2. "How would you add undo or move history?"​
Undo is a very common follow-up question because it tests whether your design has a clean separation between orchestration (Game) and state (Board). Since all moves flow through Game.makeMove , you already have a single choke point where moves can be recorded.
In most interviews that would be enough. However, if the interviewer asks for more detail with regards to the implementation, you can use the following light pseudo-code to guide your answer:
Define a tiny value object:
class Move:
- player: Player
- row: int
- col: int
+ new Move(player, row, col)
Add a history stack in Game :
class Game:
- moveHistory: Stack<Move>
+ makeMove(player, column):
...
int row = board.placeDisc(column, player.getColor())
moveHistory.push(new Move(player, row, column))
...
Add a clearCell helper to Board :
class Board:
+ clearCell(int row, int col):
grid[row][col] = null
Then undo becomes:
bool undoLastMove() {
if (moveHistory.isEmpty()) return false
Move last = moveHistory.pop()
// revert board state
board.clearCell(last.row, last.col)
// revert turn order
currentPlayer = last.player
// recompute state (simplest version)
state = IN_PROGRESS
winner = null
return true
}
You can mention that a production version might recompute win state more cleverly, but for an interview, this is more than enough.
3. "How would you add a computer opponent?"​
This follow-up is testing whether you can extend behavior without ripping through all your existing classes. The key is that rules don't change : Game still enforces turns and validity, and Board still owns grid logic. A bot just chooses a column instead of a human.
A simple way to describe it is with a separate BotEngine :
class BotEngine:
+ chooseMove(Game game, Player bot) -> int
A trivial implementation might just pick the first valid column:
int chooseMove(Game game, Player bot) {
Board board = game.getBoard()
for (int col = 0; col < board.getCols(); col++) {
if (board.canPlace(col)) {
return col
}
}
return -1 // no moves available
}
Then wherever you drive the game loop:
Game game = new Game(humanPlayer, botPlayer)
BotEngine bot = new BotEngine()
while (game.getGameState() == IN_PROGRESS) {
Player current = game.getCurrentPlayer()
int column
if (current == humanPlayer) {
column = /* read from UI / input */
} else {
column = bot.chooseMove(game, current)
}
game.makeMove(current, column)
}
The important interview point:
- We don't change
Boardat all. - We don't change
makeMoveor the game rules. - We just add a thin decision-making layer that chooses a column on behalf of a
Player.
One alternative which leans heavier into an object-oriented approach is to make Player an interface with HumanPlayer and BotPlayer implementations, where BotPlayer uses a BotEngine internally to pick a column. Either way, the core design doesn't change—the bot is just a different way to decide which column to pass into makeMove .
But I'd argue the BotEngine approach is actually the better design. A human player doesn't "do" anything—they're just data. Making Player an interface adds abstraction without value. Keeping Player as simple data and separating identity from decision making is cleaner.
What is Expected at Each Level?​
Ok so what am I looking for at each level?
Junior​
At the junior level, I'm checking whether you can decompose the problem into logical pieces and implement a working game. You should identify that you need something to represent the board, something to represent players, and something to orchestrate turns. The exact class names don't matter as much as having sensible responsibilities assigned to each. Your placeDisc logic should work - find the lowest empty row and place the disc. Win checking is the tricky part. I expect you to at least check horizontal and vertical wins correctly. Diagonal checking is harder, and it's fine if you need hints. Edge cases like full columns or playing out of turn should be handled, even if your error handling is basic (returning false is fine). If you can play a complete game from start to finish with your code and it correctly identifies a winner or draw, you're doing well.
Mid-level​
For mid-level candidates, I expect a cleaner separation of concerns without needing guidance. Game should handle orchestration and turn management. Board should own grid state and win detection. Player should be minimal data, not loaded with game logic. Your makeMove method should validate state before mutating anything—check game state, check turn order, check column validity, then place. The win-checking implementation should handle all four directions cleanly. I like seeing the directional vector approach ( (dr, dc) pairs) rather than four separate methods, because it shows you recognize the pattern. You should be able to discuss at least one extensibility scenario, like undo or configurable board size, and explain where the changes would live without actually implementing them.
Senior​
Senior candidates should produce a design that I'd be comfortable reviewing as production code. The class boundaries should be obvious and well-justified. You should proactively point out design decisions: why Player is just data, why GameState is an enum rather than boolean flags, why win checking lives on Board rather than Game. Your checkWin implementation should be elegant - the direction vector pattern with a single countInDirection helper that handles all four cases. I expect you to catch your own edge cases during implementation, not wait for me to point them out. If I ask about extensibility, you should be able to discuss multiple approaches with tradeoffs. For adding a bot opponent, you'd recognize that game rules don't change, you just need a decision making component that picks columns. Strong senior candidates often finish early and can discuss how the design would change for a networked multiplayer version or how you'd add spectator support.