Skip to main content
Low-Level Design · lesson 3 of 18
Open the Low-Level Design page →
Low-Level Design beginner Lesson 3 of 18

Design Tic-Tac-Toe: Board, Players, and Win Detection

Split a game into a Board that only knows about cells and a Game that only knows about turns, then check all eight winning lines after every move.

By SkillByExample · Published

lld oop python games

Tic-tac-toe looks trivial until you write the win check — eight lines, checked after every move.

Board and Game as separate responsibilities

class Board:
    def __init__(self):
        self.cells = [" "] * 9

    def place(self, position, mark):
        if self.cells[position] != " ":
            print(f"Cell {position} already taken")
            return False
        self.cells[position] = mark
        return True

    def render(self):
        rows = [self.cells[i:i+3] for i in range(0, 9, 3)]
        for row in rows:
            print(" | ".join(row))
        print()

    def winner(self):
        lines = [
            (0,1,2), (3,4,5), (6,7,8),   # rows
            (0,3,6), (1,4,7), (2,5,8),   # columns
            (0,4,8), (2,4,6),            # diagonals
        ]
        for a, b, c in lines:
            if self.cells[a] != " " and self.cells[a] == self.cells[b] == self.cells[c]:
                return self.cells[a]
        return None

class Game:
    def __init__(self):
        self.board = Board()
        self.players = ["X", "O"]
        self.turn = 0

    def play(self, position):
        mark = self.players[self.turn % 2]
        if not self.board.place(position, mark):
            return
        self.board.render()
        winner = self.board.winner()
        if winner:
            print(f"{winner} wins!")
        self.turn += 1

game = Game()
moves = [0, 3, 1, 4, 2]
for m in moves:
    game.play(m)
$ python tictactoe.py
X |   |  
  |   |  
  |   |  

X |   |  
O |   |  
  |   |  

X | X |  
O |   |  
  |   |  

X | X |  
O | O |  
  |   |  

X | X | X
O | O |  
  |   |  

X wins!

Game.play alternates marks with self.turn % 2, delegates the actual write to Board.place, and asks Board.winner() after every move — it never inspects cells directly. Board doesn’t know or care that X and O take turns; it just enforces “one mark per empty cell” and “these eight triples decide the game.”

Practice

1. Play out a full board that ends in a draw.
class Board:
    def __init__(self):
        self.cells = [" "] * 9

    def place(self, position, mark):
        if self.cells[position] != " ":
            return False
        self.cells[position] = mark
        return True

    def is_full(self):
        return " " not in self.cells

    def winner(self):
        lines = [
            (0,1,2), (3,4,5), (6,7,8),
            (0,3,6), (1,4,7), (2,5,8),
            (0,4,8), (2,4,6),
        ]
        for a, b, c in lines:
            if self.cells[a] != " " and self.cells[a] == self.cells[b] == self.cells[c]:
                return self.cells[a]
        return None

class Game:
    def __init__(self):
        self.board = Board()
        self.players = ["X", "O"]
        self.turn = 0

    def play(self, position):
        mark = self.players[self.turn % 2]
        if not self.board.place(position, mark):
            return
        winner = self.board.winner()
        if winner:
            print(f"{winner} wins!")
        elif self.board.is_full():
            print("Draw!")
        self.turn += 1

game = Game()
moves = [0, 1, 2, 4, 3, 5, 7, 6, 8]
for m in moves:
    game.play(m)
$ python draw.py
Draw!

is_full() is only checked once winner() has already come back empty, so a full board that also happens to be a winning line still reports the win, not a draw.

2. Confirm placing on an already-taken cell is rejected.
class Board:
    def __init__(self):
        self.cells = [" "] * 9

    def place(self, position, mark):
        if self.cells[position] != " ":
            print(f"Cell {position} already taken")
            return False
        self.cells[position] = mark
        print(f"{mark} placed at {position}")
        return True

board = Board()
board.place(4, "X")
board.place(4, "O")
$ python taken_cell.py
X placed at 4
Cell 4 already taken

place returns False on a rejected move — the caller (Game.play) already checks this return value and skips rendering/win-checking when it’s False, so a rejected placement never advances the turn either.

3. Make the game refuse further moves once it has a winner.
class Board:
    def __init__(self):
        self.cells = [" "] * 9

    def place(self, position, mark):
        self.cells[position] = mark

    def winner(self):
        lines = [
            (0,1,2), (3,4,5), (6,7,8),
            (0,3,6), (1,4,7), (2,5,8),
            (0,4,8), (2,4,6),
        ]
        for a, b, c in lines:
            if self.cells[a] != " " and self.cells[a] == self.cells[b] == self.cells[c]:
                return self.cells[a]
        return None

class Game:
    def __init__(self):
        self.board = Board()
        self.players = ["X", "O"]
        self.turn = 0
        self.finished = False

    def play(self, position):
        if self.finished:
            print(f"Game over, move to {position} ignored")
            return
        mark = self.players[self.turn % 2]
        self.board.place(position, mark)
        winner = self.board.winner()
        if winner:
            print(f"{winner} wins!")
            self.finished = True
        self.turn += 1

game = Game()
for m in [0, 3, 1, 4, 2, 8]:
    game.play(m)
$ python finished.py
X wins!
Game over, move to 8 ignored

self.finished is exactly the same idea as the vending machine’s state variable two lessons ago — one flag that, once set, makes an entire class of further calls a no-op instead of trusting every caller to stop calling play() on their own.

Next: SOLID’s Open/Closed Principle, and why an if/elif chain of shapes breaks every time a new shape is added.

Frequently Asked Questions

Why is Board a separate class from Game instead of one class doing everything?
Board owns the grid and the rules for what counts as a win — it has no idea whose turn it is or how many players exist. Game owns turn order and player marks — it has no idea how winning is detected. Splitting them means the win-detection logic could be reused for a different game mode (say, a 4x4 variant) without touching turn logic at all, and vice versa.
Why check all eight lines (3 rows, 3 columns, 2 diagonals) after every single move instead of just checking the board once at the end?
A move can only ever complete a line that passes through the cell just played, but checking all eight anyway is cheap (9 cells, 8 lines) and radically simpler than figuring out which lines a given position belongs to. At tic-tac-toe's size, checking everything every time is the right tradeoff — it wouldn't scale to a 19x19 Go board, but LLD problems are about picking the right tradeoff for the actual size of the problem, not defaulting to the most 'efficient' possible approach.
What does the walrus-free `self.cells[a] == self.cells[b] == self.cells[c]` chained comparison actually check?
Python chains comparisons, so `a == b == c` is `a == b and b == c` — true only if all three are equal. Combined with the `self.cells[a] != " "` check first, a line only counts as a win if all three cells are filled with the *same* non-empty mark.
Why does the main example use a hardcoded moves list instead of reading input?
This lesson is about the Board/Game class design, not I/O — a scripted move list makes the example deterministic and directly runnable without a terminal attached, which is exactly the property this site's lessons all need (real output, reproducible every time).