My first bot picked squares at random. I beat it in 3 moves.

I’m working through LLD problems, and tic-tac-toe was the third one I picked. Building it for two players was the easy part. Then I added a bot, and it hooked me for days. Every time I beat it, I added a new rule. Every time I added a rule, I found a new way to beat it. This went on until one day I just… couldn’t win anymore. Only draws.

This post isn’t about the board, the turns or undo (that part is boring, trust me). It’s about how the bot thinks. Let’s see how I made it.


How the bot is wired

Before the levels, one thing you need to know: the bot doesn’t have one big brain. It has a list of small brains, and it asks them one by one.

1
2
3
public interface BotStrategy {
    Cell makeMove(Board board, Player player);
}

Each strategy either returns a cell (“I’ve got this”) or null (“not my job, ask the next guy”). The bot takes the first answer that isn’t null.

1
2
3
4
5
6
for (BotStrategy strategy : botStrategy) {
    Cell cell = strategy.makeMove(board, this);
    if (cell != null) {
        return new Move(cell.getRow(), cell.getCol(), this);
    }
}

It works like a waterfall. In design-pattern terms it’s Strategy + Chain of Responsibility. The nice part: adding a new level means adding one new class. Bot never changes.

The bot asks each strategy in order — Win, Block, Fork, BlockFork, Center, Corner, Random — and plays the first non-null answer


Level 1 — The blind swordsman

Random Strategy

I needed somewhere to start, and this was the spot. Pick a random row and column that is not occupied, and return it.

Playing against this was like a sword fight with a blind opponent. He’s swinging hard, really trying to attack you, but whether he hits anything is pure luck.

Random bot: I win down the diagonal in three moves while the bot plays two random squares

Center Strategy

After a few games I noticed something: the center is the key square. It sits on 4 lines (a row, a column and both diagonals), more than any other square. If our bot grabs it, it gets a head start.

So this strategy is simple: if the center is empty, take it.

Corner Strategy

Same idea, but for corners. Each corner sits on 3 lines, so they’re the next best thing. With this added, the bot really felt like it was trying sometimes.

All four corners go in a simple list, using last = size - 1 so it works for any board size. Then we take the first one that’s still empty:

1
2
3
4
5
6
7
8
9
int last = board.getSize() - 1;
int[][] corners = {{0, 0}, {0, last}, {last, 0}, {last, last}};

for (int[] rc : corners) {
    Cell cell = board.getCell(rc[0], rc[1]);
    if (cell.isEmpty()) {
        return cell;
    }
}

Random + Center + Corner together — that’s our level 1 bot. Still very easy to beat. It takes good squares, but it has no idea what I’m doing.

The bot takes the center and corners, but I still finish row 2


The turning point — everything is just a line

Before I could teach the bot to defend, I had to fix how it sees the board.

At first every strategy walked the rows, then the columns, then the diagonals, all by hand. Copy-paste everywhere.

Then I looked closer. Rows, columns, diagonals… they’re all just lines. A 3×3 board has 8 of them: 3 rows, 3 columns, 2 diagonals.

So GridStatus slices the board into those 8 lines, and each line becomes a LineStats: how many of each symbol it has, and which cells are still empty.

Row 0:  X | X | .    →  X: 2, O: 0, empty: [(0,2)]

Now every strategy is just a one-line question on the same data:

  • Can I win here? → I have 2, and 1 cell is empty.
  • Do I need to block? → I have 0, and 1 cell is empty.
  • Is this a fork line? → I have 1, and 2 cells are empty.

Nothing walks the board by hand anymore, so there’s no copy-paste left to get wrong.

The 3x3 board split into 8 lines, with the LineStats for row 0: two X, zero O, one empty cell


Level 2 — The shield and the sword

Block Strategy

I should’ve added this to our arsenal way earlier. This is where the bot got its first shield.

The problem: I’d line up two X’s, and the bot just… didn’t care. So now it checks every line for one where I have all but one cell and it has nothing. If it finds one, it plugs the hole.

1
2
3
if (stat.count(player.getSymbol()) == 0 && stat.getEmptyCells().size() == 1) {
    return stat.getEmptyCells().get(0);
}

This doesn’t mean we win. But at least we can delay the loss.

Before and after Block: the bot plugs (0,2) instead of playing anywhere

Win Strategy

Now it was time to get our sword too. Any time there’s a victory on the table, the bot should grab it. It works like Block, just flipped: find a line where we need one more piece.

With the sword and shield in hand, we had a level 2 bot. It had some tricks up its sleeve, but in the real world it was still an amateur. Because I had a new weapon.

Win comes first: the bot completes row 1 instead of blocking


Level 3 — The fork

Every game has moves that put you at an advantage, and in tic-tac-toe that move is the fork: one move that creates two threats at once. The opponent can only block one. The other one wins.

It’s a lethal weapon. I was beating the level 2 bot with it all the time, so it needed one too.

Finding a fork: let the lines vote

This part was a little tricky, so I built a voting system.

Go over every line. If a line has one of my pieces and two empty cells, it’s one move away from becoming a threat. So each of those two empty cells gets a vote.

1
2
3
4
5
6
7
for (LineStats stat : stats) {
    if (stat.count(symbol) == size - 2 && stat.getEmptyCells().size() == 2) {
        for (Cell cell : stat.getEmptyCells()) {
            threatCount.merge(cell, 1, Integer::sum);
        }
    }
}

(On 3×3, size - 2 is just 1 piece.)

Any cell with 2 or more votes sits on two of those lines at once. Play it, and both lines become threats. That’s a fork.

Vote grid: column 0 and row 2 both vote for (2,0), making it a fork cell

Why it’s third in line

Fork is powerful, but it’s not first priority. If we can win right now, win (Win). If the opponent can win right now, block it (Block). Only then do we think about forks.

Once the fork landed, we had the victory. That’s our level 3 bot.


Level 4 — Blocking forks (and the trap)

What’s lethal for the enemy is lethal for us too. Now I had to teach the bot to stop my forks.

The first idea was easy: run the same vote from my side. If I have exactly one fork cell, the bot just takes it. Done.

But what if I have two?

The trap

 X | . | .
 . | O | .
 . | . | X

I play two opposite corners, the bot is in the center. Run the vote for X, and I have two fork cells: (0,2) and (2,0).

The old bot did what seemed smart: it took one of them.

 X | . | O
 . | O | .
 . | . | X

Looks like a block, right? But now O has two on the anti-diagonal, so I have to block at (2,0)… which is my other fork cell. Now I have two threats (column 0 and row 2), and the bot can only stop one.

The bot “blocked the fork” and still lost. I laughed for a good minute.

The trap in three steps: the bot takes one fork cell and gets forced into losing

The fix: a forcing move

Instead of chasing my fork cells, the bot makes one threat of its own, so I’m forced to block. And it picks that threat carefully: the cell I’m forced to play must not be one of my fork cells.

1
2
3
4
5
6
7
8
for (LineStats stat : gridStatus.getAllLineStats(board)) {
    if (stat.count(me) == size - 2 && stat.getEmptyCells().size() == 2) {
        Cell a = stat.getEmptyCells().get(0);
        Cell b = stat.getEmptyCells().get(1);
        if (!forks.contains(b)) return a;   // I play a → they must block b (safe)
        if (!forks.contains(a)) return b;   // I play b → they must block a (safe)
    }
}

In the trap, the bot plays the edge:

 X | . | .
 O | O | .
 . | . | X

Now I have to block at (1,2). That’s not a fork cell, so it’s harmless. The bot blocks my one remaining threat, and the game ends in a draw.

I replayed this game a lot. Draw. Every time.

And with that, the bot was unbeatable. Not the minimax approach everyone uses, just my own rules, but it works.

The forcing move: the bot plays the edge, X is forced to (1,2), which is not a fork cell


The aha: build order ≠ priority order

Here’s what surprised me. I built Center before Block. But the bot must check Block first. The order I learned things isn’t the order the bot should think.

The final chain:

1
2
3
4
5
6
7
8
9
BotStrategy[] botStrategy = {
    new WinStrategy(gridStatus),
    new BlockStrategy(gridStatus),
    new ForkStrategy(gridStatus),
    new BlockForkStrategy(gridStatus, alice),
    new CenterStrategy(),
    new CornerStrategy(),
    new RandomStrategy(),
};

Why this order?

  • Win and Block come first because they’re about this move. Forks are about the next one.
  • Fork comes before BlockFork because if I fork, the opponent has to answer my threats. Their fork never gets played.
  • Random is last, always. It never returns null, so anything after it would never get a turn.

Order I built the strategies versus the order the bot checks them


Wrapping up

I can’t beat it anymore. Only draws.

The funny part: only two of the seven strategies (Fork and BlockFork) look ahead at all. The rest just read the board as it is right now.

My rulesMinimax
How it worksHand-written tic-tac-toe knowledgeTries every possible game
SpeedInstantSlower (it searches everything)
Works on3×3 only, reallyAny board

Next post, I’m throwing all these rules away and building the same unbeatable bot with zero tic-tac-toe knowledge: minimax.

The full code is on GitHub: system-design/lld/tic-tac-toe