By Yangqing
How to Build a Gomoku AI in Python: A Working Guide
Gomoku is one of the better games for learning how a board-game AI actually works, because the rules are small enough to state in a paragraph and the tactical structure is rich enough that a weak engine and a strong one feel visibly different to a human player. This article builds a real, runnable engine from scratch in Python, in four stages, and explains at each stage why the change matters rather than just handing you a block of code to paste. It is not a tutorial in general Python, and it is not a research paper on threat-space search; it is the gap between "I understand minimax in the abstract" and "I have a program that plays a real game and I can tell when it gets better."
What you will build: a 15×15 free-style Gomoku engine that finds immediate wins and blocks, ranks candidate moves by local pattern value, and searches with minimax plus alpha-beta pruning to a chosen depth. Its strength and speed depend on the position and search depth; test them rather than assuming a particular result.
Stage 1: Represent the Board
The whole engine sits on a 15×15 integer array. Zero is empty, 1 is your stone, 2 is the opponent's. That is the entire data structure; there is no need for anything more elaborate at this scale. The one design decision that matters more than it looks like: when you place a stone, only check the four lines that pass through that stone (horizontal, vertical, and the two diagonals). A new stone cannot complete a line anywhere else on the board, so checking whether that particular move wins needs only those lines. The full-board terminal scan in Stage 4 remains simple for teaching, but could later use the last move to avoid repeated work.
SIZE = 15
def empty_board():
return [[0] * SIZE for _ in range(SIZE)]
def on_board(r, c):
return 0 <= r < SIZE and 0 <= c < SIZE
# The four line directions a new stone can belong to
DIRECTIONS = [(0, 1), (1, 0), (1, 1), (1, -1)]
def lines_through(r, c, board, player):
"""Return the count of consecutive stones in each of the four lines
through (r, c), for the given player, including the new stone."""
counts = []
for dr, dc in DIRECTIONS:
forward = 1
rr, cc = r + dr, c + dc
while on_board(rr, cc) and board[rr][cc] == player:
forward += 1
rr, cc = rr + dr, cc + dc
backward = 0
rr, cc = r - dr, c - dc
while on_board(rr, cc) and board[rr][cc] == player:
backward += 1
rr, cc = rr - dr, cc - dc
counts.append(forward + backward)
return counts
With lines_through you have everything you need to detect a win: any count of five or more is a
completed line, under the "five or more wins" rule used here. If you are playing exact-five instead, add a
check that the stone on each side of a five is not also the same color, so that a six-stone line does not
count as a win. The rules guide explains the difference, and it
matters because the two rule sets can give opposite answers about whether a particular move wins.
Stage 2: Generate Moves, Not 225 of Them
The naive move generator returns every empty square, and it is formally correct but useless in practice: most empty squares are far from any stone and unlikely to matter immediately. A radius filter limits candidates to empty squares within two squares of an existing stone. This is a heuristic, not a rule: it can drop useful moves, especially early in the game. The code special-cases only an empty board; experiment with a wider radius if that restriction matters for your positions.
def candidate_moves(board, radius=2):
occupied = [(r, c) for r in range(SIZE) for c in range(SIZE)
if board[r][c] != 0]
if not occupied:
return [(SIZE // 2, SIZE // 2)] # first move: the center
candidates = set()
for r, c in occupied:
for dr in range(-radius, radius + 1):
for dc in range(-radius, radius + 1):
rr, cc = r + dr, c + dc
if on_board(rr, cc) and board[rr][cc] == 0:
candidates.add((rr, cc))
return sorted(candidates) This reduces the number of candidates when stones are clustered, making a short minimax search more practical. The number of candidates and the time required still depend on the position, search depth, and machine.
Stage 3: Evaluate a Position by Pattern
The evaluation function scores a position by counting patterns for both players and returning a number from the point of view of the side to move. The values below are a reasonable starting point, not a law: the exact numbers matter less than the ordering, which is that a win dwarfs everything else, a loss is its negative, and the intermediate patterns are ordered by how close they are to becoming one of those.
WIN = 1_000_000
LOSE = -1_000_000
# line-length -> score (a dict would also work; a tuple list avoids
# a parser that reads a bare int key as a JavaScript object literal)
PATTERN_SCORES = [(5, 100_000), (4, 10_000), (3, 1_000), (2, 100)]
def score_lines(counts):
total = 0
for n in counts:
if n >= 5:
total += PATTERN_SCORES[0][1]
else:
for length, score in PATTERN_SCORES[1:]:
if n == length:
total += score
break
return total
def evaluate(board, player):
me = 0
them = 0
for r in range(SIZE):
for c in range(SIZE):
if board[r][c] == player:
me += score_lines(lines_through(r, c, board, player))
elif board[r][c] == 3 - player:
them += score_lines(lines_through(r, c, board, 3 - player))
return me - them One subtle point: this version re-scans the same lines from every stone, which double-counts a four or five that several stones in the same line each report. That makes scores heuristic rather than numerically exact; even their ordering can be misleading in some positions. To improve it, count each line once rather than once per stone in it. Test this simpler starting point against fixed tactical positions.
Stage 4: Minimax With Alpha-Beta Pruning
Minimax assumes the opponent plays the move that is worst for you, and it works back from there. Alpha-beta pruning is the optimization: if a branch is already worse than one you have seen for the opponent, you do not need to finish exploring it, because the opponent will never choose a worse line. The two numbers alpha and beta are just bookkeeping for "the best the maximizing side has guaranteed so far" and "the best the minimizing side has guaranteed so far."
def winner(board):
for r in range(SIZE):
for c in range(SIZE):
p = board[r][c]
if p and max(lines_through(r, c, board, p)) >= 5:
return p
return None
def ordered_moves(board, player):
ranked = []
for r, c in candidate_moves(board):
board[r][c] = player
score = evaluate(board, player)
board[r][c] = 0
ranked.append((score, r, c))
ranked.sort(key=lambda item: -item[0])
return [(r, c) for _, r, c in ranked]
def minimax(board, depth, player, root_player, alpha, beta):
# Keep scores in root_player's perspective at every level.
won_by = winner(board)
if won_by is not None:
return WIN if won_by == root_player else LOSE
if not any(0 in row for row in board):
return 0 # draw: do not evaluate or generate a move
if depth == 0:
return evaluate(board, root_player)
maximizing = player == root_player
best = -float('inf') if maximizing else float('inf')
for r, c in ordered_moves(board, player):
board[r][c] = player
value = minimax(board, depth - 1, 3 - player, root_player, alpha, beta)
board[r][c] = 0
if maximizing:
best = max(best, value)
alpha = max(alpha, best)
else:
best = min(best, value)
beta = min(beta, best)
if beta <= alpha:
break # prune
return best
def choose_move(board, player, depth=3):
if winner(board) is not None or not any(0 in row for row in board):
return None
moves = ordered_moves(board, player)
opponent = 3 - player
for r, c in moves:
board[r][c] = player
wins = max(lines_through(r, c, board, player)) >= 5
board[r][c] = 0
if wins:
return (r, c)
for r, c in moves:
board[r][c] = opponent
must_block = max(lines_through(r, c, board, opponent)) >= 5
board[r][c] = 0
if must_block:
return (r, c) # a forced block when there is one winning square
best_value = -float('inf')
best_move = None
for r, c in moves:
board[r][c] = player
value = minimax(board, depth - 1, opponent, player, -float('inf'), float('inf'))
board[r][c] = 0
if value > best_value:
best_value = value
best_move = (r, c)
return best_move The move chooser checks its own immediate wins before the opponent's immediate winning squares, so it does not miss a direct win just to block. When the opponent has two separate winning squares, the first block returned is not a guaranteed defense. The search then tries candidates in descending one-move pattern-evaluation order; this can help alpha-beta pruning, but the heuristic and the actual search time vary by position. Terminal results and draws are checked before the depth limit, and every score remains in the root player's perspective.
Testing It Without Guessing
The honest question after this stage is "how good is it really," and the answer comes from a small, fixed set of test positions, not from playing it a few times against yourself and forming an impression. Build a handful of boards with a known answer: one where your side has an open four and must take the winning square, one where the opponent has a four with one winning square, and one where the necessary block is not the highest-scoring move a naive evaluation would pick. Add a full board without a winner to confirm it returns no move. Run your engine on each and check it picks the right square. If it fails the block case, that is the specific bug to fix, not a general feeling that "it is a little weak."
This test-position discipline is the same habit a human player uses when reviewing a finished game, and it is worth noting the two run on the same principle: a position is understood when you can name the one move that matters, not when you have a number that feels reasonable. The glossary is a useful reference for the pattern names you will be writing test cases in.
Where This Design Stops, and What Comes Next
The engine above is a good place to stop, and a good place to start learning what comes after it, because its limitations are specific and instructive. It does not recognize a VCF, a win built from a chain of forcing threats, as a single object; it only sees one move at a time through its depth. It does not know about Renju's forbidden moves, so if you switch rule sets, a move it recommends as the best attack can be illegal. And its evaluation, because it double-counts lines, can overvalue a position that has several medium patterns and undervalue one that has a single sharp one. Each of these is a known, named next step, which is the whole point of stopping here: you are not stopping because the code stops working, you are stopping because the next improvement has a specific, describable target.
If you want to go further, the next article to read is not a harder version of this one. It is The Science of Gomoku, which explains the threat-space search idea this article deliberately leaves out, and Is Gomoku Solved?, which explains what a "solved" game actually means and why it does not make casual play any less interesting. This article builds the engine; those two explain the research that makes a real one strong.
Frequently Asked Questions
How deep should minimax search in Gomoku?
Start with a shallow depth and measure the time and move choice on fixed positions. The candidate count depends on the board, and search cost rises quickly with depth; there is no reliable depth or runtime guarantee for this example.
Why not start with a neural network?
You can, but a network needs training data and a way to evaluate its play. If your goal is to understand minimax and pattern evaluation, build the transparent version first; then compare any replacement on the same fixed test positions rather than assuming it plays better.
Does this engine handle Renju forbidden moves?
No, it assumes free-style five-or-more rules. If you switch to Renju, you need a legality check for Black's double-three, double-four, and overline restrictions before a move is allowed. That is a specific, well-defined addition, not a rewrite, but it has to be there or the engine will recommend illegal moves under the stricter ruleset.
What is the single biggest improvement you can make after this?
Immediate-win and immediate-block prechecks and one-ply pattern-based move ordering are already included. Try deduplicating scored lines, improve threat recognition, and measure each change against fixed positions before claiming a strength gain.
Can I use the engine on this site to test my own code against?
Yes, the browser game has a local AI at three difficulty levels, which is a reasonable way to sanity-check that your own engine plays at the level you expect. It is not a substitute for the fixed test positions, but it is a quick way to feel whether a change made the engine stronger or weaker.
Play a Game While You Learn
The local AI on this site is a fine way to feel the difference between the engine in this article and a human's own play.
Open the board →Reviewed 2026-10-13. For the search techniques this article leaves out, see The Science of Gomoku.