Beginner22 min readMachine Coding Practicelive prototype

Tic-Tac-Toe

Everyone can code this in fifteen minutes. That is exactly why it is asked — the interviewer is not checking whether you can detect three in a row, they are checking whether you hardcoded the 3. The good answer is an n×n, k-in-a-row engine that detects a win in O(1) without ever re-reading the board.

The idea

What it is

“Design tic-tac-toe.” It is the friendliest prompt in the set, and it hides a trap: the problem is so small that a working solution is not evidence of anything. Nested ifs over nine hardcoded cells will play a correct game — and score badly.

What is actually being asked is: can you build a small game engine? Something where the board size is data, the win rule is a rule, and adding a second player or a bigger grid does not mean rewriting the win check. That is the difference between coding a puzzle and designing a system.

Board — n × n, not 3 × 3 X O X Cell Piece (X / O) Player “Asha” piece = X Player “Ravi” piece = O Move row 1 · col 1 player Asha
Five nouns, and only one of them is interesting. A Move is a small immutable record — that single decision is what later gives you undo, replay and a game log for free.

The whole system in three sentences

A Board of n × n cells and a queue of Players. play(row, col) validates the move, writes the piece, and updates a handful of counters. If any counter reaches ±n, that player has won — decided from a single number, never from a scan.

What separates a pass from a strong pass

  1. The 3 is a parameter, not a literal. If n appears as 3 anywhere outside the constructor, you have hardcoded the problem.
  2. Win detection does not scan. Rescanning eight lines every move is fine for a 3×3 grid and embarrassing on a 19×19 Gomoku board — and they will ask about Gomoku.
  3. Validation happens before mutation. An invalid move must leave the board exactly as it was.
  4. Two players is a list, not two variables. Player x, o; cannot become three players; Queue<Player> can.
  5. It runs. Print a board after each move. This is the one problem where a full game in main() is genuinely quick.

Mechanics

How it works

Step 1 · Clarify — 3 minutes, and one question matters

  • Is the board always 3×3? — the question that shapes everything. Answer for yourself: build it as n×n regardless, because it costs nothing.
  • Is a win always a full line? — on 3×3 yes, but on a 5×5 board it is usually k in a row. Ask, then build n and treat k = n as the default.
  • Two players, or more? — build a queue. Three players on a 5×5 board is a real variant and costs you one line.
  • Do we need an AI opponent? — almost always no. If yes, it is a separate Player implementation and you should say so, not start writing minimax.
  • Undo? Replay? A game log? — all three fall out of keeping a move history, so build the history even if they say no.

The trap is agreeing to 3×3 too eagerly

“Let's keep it 3×3” sounds like the interviewer being kind. Ten minutes later comes “now make it 10×10 with 5 in a row”, and a solution built around eight hardcoded lines has to be rewritten. Building n×n from the start costs about four extra lines.

Step 2 · Win detection — the whole reason this problem is asked

The obvious approach after every move: check all n rows, all n columns and both diagonals. That is O(n²) work per move, and it re-reads cells that have not changed since the game began.

But only one cell changed. And a cell can only ever belong to four lines: its row, its column, and the two diagonals if it happens to sit on them. So keep a running total per line and update just those.

X = +1 · O = −1 · a line is won when its counter reaches ±n X O X r0 0 r1 +1 r2 0 c0 +1 c1 0 c2 0 \ +2 / +1 X played (1,1) 4 counters touched: r1, c1, \, / every other counter is untouched and still correct — nothing rescanned the centre is the only cell on both diagonals; a corner touches one; an edge touches none
The +1 / −1 trick is what makes one integer per line enough. A counter can only reach +n if every cell in that line is X, and −n only if every cell is O — mixed lines cancel out on the way.
the entire win check
// after writing the piece at (row, col), where value is +1 for X and -1 for O
rows[row] += value;
cols[col] += value;
if (row == col)             diagonal += value;
if (row + col == n - 1)     antiDiagonal += value;

boolean won = Math.abs(rows[row]) == n
           || Math.abs(cols[col]) == n
           || Math.abs(diagonal) == n
           || Math.abs(antiDiagonal) == n;
RESCAN — O(n²) per move COUNTERS — O(1) per move 3 × 3 9 cells read ≤ 4 numbers read 10 × 10 100 cells read ≤ 4 numbers read 19 × 19 361 cells read — every move ≤ 4 numbers read the counters cost n + n + 2 integers of memory — a rounding error next to the board itself
On a 3×3 board the difference is meaningless and you should say so. It becomes the whole answer the moment they say “now make it Gomoku” — which is why they ask.

When k is smaller than n

Once a win is k in a row on an n×n board (Connect 4, Gomoku), a single counter per line is no longer enough — a line can contain both players. The move is to scan outward from the placed cell in the four directions, counting consecutive matches. That is O(k) per move, still independent of . Know that this is the next step; you rarely have to build it.

Step 3 · Validate before you mutate

Three things can be wrong with a move, and all three must be caught before the board changes. A half-applied move — piece written, counters not updated — is unrecoverable state.

CHECK — nothing has changed yet game still in progress ? coordinates on the board ? cell empty ? all three pass MUTATE — in this order, no early exits 1 write the piece 2 update counters 3 check for a win 4 next player step 4 is skipped when step 3 found a winner — that is the only branch in the whole method
Try it in the prototype: click an occupied cell. The cell shakes, the move counter does not move, and no counter changes. A rejected move is a no-op, not a partial one.

Step 4 · The class diagram

Game - players : Deque<Player> - history : List<Move> + play(r, c) · undo() Board - n : int - rows[], cols[] - diag, antiDiag Cell 2..* Player - name, piece Piece «enum» X · O Move row, col, player immutable → undo, replay asks «interface» WinCondition + isWin(board, move) FullLine (tic-tac-toe) KInARow (Gomoku) Gravity (Connect 4)
Two decisions do all the work: n is a field on the Board, and the win rule is an interface. Everything the interviewer asks next is one of those two boxes. Notation: Class diagrams.

Why Move should be immutable

A move is a fact about the past: this player put this piece here. Facts do not change. Keeping the list of them gives you undo (replay the counter arithmetic backwards), replay (apply them to a fresh board) and a game log — three features for one design decision. Related: Immutability & value objects and Command.

Undo is subtraction, not a snapshot

Because a move touched at most four counters and one cell, undoing it touches exactly the same things with the sign flipped. No board copies, no history of full states.

✗ SNAPSHOT EVERY BOARD store a full n×n copy after each move O(n²) memory per move ✓ REVERSE THE MOVE RECORD pop the Move, clear the cell, subtract O(1) memory and time undo(Move m at (1,1), value +1) cell[1][1] = empty rows[1] −= +1 cols[1] −= +1 diags −= +1
Press ⎌ Undo in the prototype after a winning move and watch every counter come back down. This is the Command pattern in miniature — an action that knows how to reverse itself.

One move, message by message

Player Game Board WinCondition play(1, 1) isEmpty(1, 1) ? true — safe to write place(X, 1, 1) rows[1]++ cols[1]++ \++ /++ isWin(board, move) ? |\| == n → true WIN
Four messages. Game orchestrates, Board owns the data and the counters, and WinCondition reads but never writes. Notation: Sequence diagrams.

The follow-ups — this is a single family of games

“NOW MAKE IT…” WHAT CHANGES 10 × 10 tic-tac-toe nothing — n was always a parameter Gomoku — 5 in a row on 19 × 19 +1 class: KInARow win condition Connect 4 +gravity on placement, +KInARow three players one more entry in the queue
Two of the four are free, and they are free because of decisions you made in the first ten minutes. That is the return on treating n as data and the win rule as an object.
  • “Add a computer opponent.”Player becomes an interface with HumanPlayer and AiPlayer; the game loop does not change because it only ever asks a player for a move. Do not start writing minimax unless asked.
  • “Undo the last move.” → already free from the move history.
  • “Save and resume a game.” → serialise the move list, not the board. Replaying it rebuilds board and counters, so there is only one thing to persist.
  • “Watch a game live from another screen.” → the game publishes a MovePlayed event; renderers subscribe. That is Observer, and it keeps display code out of the rules.
  • “What if two players submit at once?” → in an online version, play() must be atomic on the game. Same shape as the check-and-take in Parking Lot.

How this round is lost

  • 3 hardcoded. Eight explicit line checks, and the first follow-up destroys them.
  • Rescanning the whole board every move. It works, and it tells the interviewer you did not think about what actually changed.
  • Player x, o; as two fields. Two players is a coincidence of this game, not a fact about board games.
  • No draw detection, or draw detection by scanning for empty cells. moves == n * n is one comparison.
  • A char[][] and nothing else. No Move, no history — so undo, replay and logging all become new work later.

Interactive prototype

See it. Build it. Break it.

A sandboxed, hands-on simulation — no setup, no install. Play with it as you read.

About this simulation

A playable game with its algorithm showing. Play a move and watch the right-hand panel: every mark bumps 2 to 4 counters — its row, its column, and the diagonals if it sits on one. X adds +1, O adds −1. The instant any counter hits ±n, that line is a win, and the board lights up. Nothing was ever scanned. The two boxes at the bottom keep score of that: cells read by a rescan versus counter reads — after five moves it is already 45 against 15. Then press 4×4 or 5×5: same code, same counters, bigger board. And ⎌ Undo — because a move is a tiny record, undoing is just subtraction.

Hands-on

Try these yourself

Open the prototype above, predict what happens, then verify.

try 01

Watch a single move do its work

Play the centre cell first. The explain line says 4 counters — row 1, column 1, and both diagonals, because the centre is the only cell on both. Now play a corner (3 counters) and then an edge (2 counters). The number of counters a cell touches is a property of where it sits, and that is the whole optimisation.

try 02

Win on a diagonal and read the number

Play X at top-left, O anywhere, X centre, O anywhere, X bottom-right. The \ counter goes +1 → +2 → +3, the line lights up, and the explain line tells you the board was never re-read. Check the two cost boxes: about 45 cells rescanned versus 15 counter reads, after five moves on the smallest possible board.

try 03

Try an illegal move

Click a cell that already has a piece. It shakes, the call line shows InvalidMoveException, and — the important part — the move count and every counter stay exactly where they were. Then click anywhere after a game has ended. Same story.

try 04

Undo a win

Win a game, then press ⎌ Undo. The winning counter drops from ±3 back to ±2, the highlight clears, and it is that player's turn again. No board snapshot was stored — the counters were simply run backwards.

try 05

Break the hardcoded 3

Press 5×5 and play a full row for X. The win fires at +5, not +3, and the counter panel now shows five rows and five columns with no code change. This is the exact follow-up an interviewer will throw at you; the chip exists so you can see what a parameterised n buys.

try 06

Build it from memory

Blank file, in this order: Piece enum → Move record → Board(n) with rows[], cols[], diag, antiplace() returning whether it won → Game with a player queue and history → play() with all three validations → main() that plays a full game and prints the board each move. Then add undo() — if it takes more than five lines, your Move is not carrying enough.

In practice

When to use it — and what trips people up

The pattern you just learned, generalised

The counter trick is an example of something worth naming, because it appears constantly once you look for it: keep a summary that is cheap to maintain incrementally, instead of recomputing it from scratch.

  • Running totals — a dashboard that maintains sum and count on write rather than aggregating on every read.
  • Inverted indexes — a search index updated per document instead of scanning every document per query.
  • Materialised views — the same idea, given a database name.
  • Dirty flags and memoisation — recompute only what the last change could possibly have affected.

The precondition is always the same: you must be able to work out exactly what the change affects. In tic-tac-toe a cell affects at most four lines, and that bound is what makes the whole thing valid.

When you should not bother

  • When the recompute is genuinely cheap and rare. On a 3×3 board played by humans, rescanning is free. Build the counters anyway here — but say that you know they are not needed at this size, and why you built them regardless.
  • When the incremental update is hard to get right. A summary that can silently drift out of sync with the data is worse than a slow, obviously correct scan. Counters are safe because the update is two lines and the undo is the same two lines negated.
  • When k < n. A single counter per line stops working the moment a line can hold both players' pieces — you need the directional scan instead. Knowing the limit of your own optimisation is worth as much as the optimisation.

If you only remember one thing

Only one cell changed, so only recompute what that cell could have affected. Say that sentence in the interview and then write four lines of code that do exactly it.

What it gives you

  • Win detection is O(1) per move and completely independent of board size — the answer to “now make it 19×19”.
  • The board size n is a constructor parameter, so bigger boards need zero new code.
  • Players are a queue, so three or four players is a one-line change instead of a rewrite.
  • An immutable Move history buys undo, replay, save/resume and a game log from one decision.
  • Validation strictly precedes mutation, so a rejected move leaves the board and every counter untouched.

Common mistakes

  • The +1/−1 counter scheme only works for exactly two players and a full-line win — three players or k-in-a-row need a different representation.
  • Counters duplicate information already in the board, so any code path that writes cells directly can desynchronise them.
  • On a 3×3 board the optimisation is invisible, which makes it look like over-engineering unless you explain why you built it.
  • A WinCondition interface is genuinely more machinery than a 45-minute round needs unless the interviewer asks for variants.
  • The design assumes moves arrive one at a time; an online multiplayer version needs play() to be atomic and this says nothing about that.

Reference

Code & further reading

A minimal reference implementation and pointers worth bookmarking.

import java.util.*;

enum Piece {
    X(+1), O(-1);
    final int value;                       // +1 / -1 is what makes one counter per line enough
    Piece(int value) { this.value = value; }
}

record Player(String name, Piece piece) {}

/** A fact about the past — immutable, which is what gives us undo and replay. */
record Move(int row, int col, Player player) {}

class InvalidMoveException extends RuntimeException {
    InvalidMoveException(String msg) { super(msg); }
}

class Board {
    private final int n;
    private final Piece[][] cells;
    private final int[] rows, cols;
    private int diag, antiDiag;
    private int filled;

    Board(int n) {
        this.n = n;
        this.cells = new Piece[n][n];
        this.rows = new int[n];
        this.cols = new int[n];
    }

    int size()      { return n; }
    boolean isFull(){ return filled == n * n; }
    boolean inBounds(int r, int c) { return r >= 0 && r < n && c >= 0 && c < n; }
    boolean isEmpty(int r, int c)  { return cells[r][c] == null; }
    Piece at(int r, int c)         { return cells[r][c]; }

    /** Writes the piece and updates at most four counters. Returns true if this move wins. */
    boolean place(int r, int c, Piece piece) {
        cells[r][c] = piece;
        filled++;
        int v = piece.value;
        rows[r] += v;
        cols[c] += v;
        if (r == c)          diag += v;
        if (r + c == n - 1)  antiDiag += v;

        return Math.abs(rows[r]) == n
            || Math.abs(cols[c]) == n
            || Math.abs(diag) == n
            || Math.abs(antiDiag) == n;
    }

    /** Undo is the same arithmetic with the sign flipped — no snapshots. */
    void remove(int r, int c) {
        Piece piece = cells[r][c];
        if (piece == null) return;
        cells[r][c] = null;
        filled--;
        int v = piece.value;
        rows[r] -= v;
        cols[c] -= v;
        if (r == c)          diag -= v;
        if (r + c == n - 1)  antiDiag -= v;
    }

    void print() {
        for (int r = 0; r < n; r++) {
            StringBuilder sb = new StringBuilder("   ");
            for (int c = 0; c < n; c++) sb.append(cells[r][c] == null ? "." : cells[r][c].name()).append(' ');
            System.out.println(sb);
        }
    }
}

enum Status { IN_PROGRESS, WON, DRAW }

class Game {
    private final Board board;
    private final Deque<Player> turnOrder = new ArrayDeque<>();   // a queue, so 3 players costs one line
    private final List<Move> history = new ArrayList<>();
    private Status status = Status.IN_PROGRESS;
    private Player winner;

    Game(int n, List<Player> players) {
        if (players.size() < 2) throw new IllegalArgumentException("need at least two players");
        this.board = new Board(n);
        this.turnOrder.addAll(players);
    }

    Status status()   { return status; }
    Player winner()   { return winner; }
    Player current()  { return turnOrder.peekFirst(); }
    Board board()     { return board; }

    Status play(int r, int c) {
        // ---- validate first; nothing has changed yet ----
        if (status != Status.IN_PROGRESS)   throw new InvalidMoveException("game is over");
        if (!board.inBounds(r, c))          throw new InvalidMoveException("off the board: " + r + "," + c);
        if (!board.isEmpty(r, c))           throw new InvalidMoveException("cell taken: " + r + "," + c);

        // ---- now mutate ----
        Player player = turnOrder.removeFirst();
        history.add(new Move(r, c, player));
        boolean won = board.place(r, c, player.piece());

        if (won) { status = Status.WON; winner = player; return status; }
        if (board.isFull()) { status = Status.DRAW; return status; }

        turnOrder.addLast(player);                 // rotate only when the game continues
        return status;
    }

    void undo() {
        if (history.isEmpty()) return;
        Move m = history.remove(history.size() - 1);
        board.remove(m.row(), m.col());
        if (status == Status.IN_PROGRESS) turnOrder.removeLast();   // un-rotate
        turnOrder.addFirst(m.player());
        status = Status.IN_PROGRESS;
        winner = null;
    }
}

public class Main {
    public static void main(String[] args) {
        Player asha = new Player("Asha", Piece.X);
        Player ravi = new Player("Ravi", Piece.O);
        Game game = new Game(3, List.of(asha, ravi));

        int[][] script = {{0,0},{0,1},{1,1},{0,2},{2,2}};    // X wins on the \ diagonal
        for (int[] mv : script) {
            System.out.println(game.current().name() + " plays " + mv[0] + "," + mv[1]);
            Status s = game.play(mv[0], mv[1]);
            game.board().print();
            if (s == Status.WON)  { System.out.println("=> " + game.winner().name() + " wins"); break; }
            if (s == Status.DRAW) { System.out.println("=> draw"); break; }
        }

        try { game.play(1, 0); }
        catch (InvalidMoveException e) { System.out.println("rejected: " + e.getMessage()); }

        game.undo();
        System.out.println("after undo, status=" + game.status() + ", next=" + game.current().name());

        // the follow-up: same code, bigger board
        Game big = new Game(5, List.of(asha, ravi));
        for (int i = 0; i < 5; i++) { big.play(0, i); if (big.status() == Status.IN_PROGRESS) big.play(1, i); }
        System.out.println("5x5 => " + big.status() + " by " + big.winner().name());
    }
}

References & further reading

6 sources

Knowledge check

Did it land?

Quick questions, answers revealed on submit. Sign in to save your best score.

question 01 / 08

After a move at (r, c), which lines is it enough to check for a win?

question 02 / 08

Why represent X as +1 and O as −1 rather than as two separate counts per line?

question 03 / 08

The interviewer says: “now make it 10×10 with 5 in a row to win.” What actually has to change?

question 04 / 08

Why store the players in a queue rather than as two fields x and o?

question 05 / 08

A player clicks a cell that already has a piece. What must be true afterwards?

question 06 / 08

How should a draw be detected?

question 07 / 08

Why is undo cheap in this design?

question 08 / 08

On a 3×3 board the counter optimisation saves almost nothing. Should you still build it?

0/8 answered