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 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. The final engine is strong enough to beat a casual player consistently and beatable by anyone who studies double threats. That is the honest ceiling of this particular design, and it is a useful one.
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 scanning the other 224 squares after every move is pure waste. This small choice is the difference between an engine that thinks in a fraction of a second and one that spends its whole time budget on squares that cannot possibly be the winning move.
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 will never be part of the final winning line. The standard fix is a radius filter. A move is only worth considering if it is within two squares of some existing stone, because further away it cannot join any existing line and is effectively a new, isolated opening move. That is not a universal truth: in a nearly empty position the radius can be too tight and quietly drop a good opening reply, so keep the special case "if there are very few stones, widen the radius" in the code rather than assuming the default always works.
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 list(candidates) This single change, going from 225 candidates to usually twenty to forty, is what makes the next stage possible at all. Without it, even a depth of two in minimax is slow enough to feel bad; with it, depth four or five is realistic on a normal 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. For a first engine that is acceptable, because the double-counting pushes the right patterns to the top of the ranking even if it is not numerically exact. The fix, which you will want to make eventually, is to count each line once rather than once per stone in it, but the simpler version gets you 80% of the strength for a lot less code, and that is a fair trade at this stage.
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 minimax(board, depth, player, alpha, beta):
# Terminal checks: a player already has five, or no moves are left
for r in range(SIZE):
for c in range(SIZE):
if board[r][c] != 0:
for p in (1, 2):
if board[r][c] == p and max(lines_through(r, c, board, p)) >= 5:
return WIN if p == player else LOSE
if depth == 0:
return evaluate(board, player)
opponent = 3 - player
best = -float('inf') if player == 1 else float('inf')
for r, c in candidate_moves(board):
board[r][c] = player
value = minimax(board, depth - 1, opponent, alpha, beta)
board[r][c] = 0
if player == 1:
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):
best_value = -float('inf')
best_move = None
for r, c in candidate_moves(board):
board[r][c] = player
value = minimax(board, depth - 1, 3 - player, -float('inf'), float('inf'))
board[r][c] = 0
if value > best_value:
best_value = value
best_move = (r, c)
return best_move Two additions, in order of how much they matter, take this from a toy engine to one that plays a real game. First, before you even run the search, check whether the current move wins immediately, and whether it blocks an immediate loss, because those two checks are faster and more reliable than any depth of search. Second, order the candidate moves by their pattern score before the loop, because alpha-beta pruning is dramatically more effective when the best-looking moves are tried first; good ordering turns a search that would take several seconds into one that takes a fraction of that.
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 one, and one where the only correct move is a block that is not the highest-scoring move a naive evaluation would pick. 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?
With the radius filter and move ordering above, depth 3 to 5 is the practical range on a normal machine before the search time becomes noticeable. Beyond that, the runtime grows fast enough that the extra strength is not worth it for a casual engine, and a stronger move generator is a better use of the effort than a larger depth.
Why not start with a neural network?
You can, and a trained network will play better than the engine in this article. But it does not teach you how any of it works, and it is not a useful starting point if your goal is to understand minimax and pattern evaluation. Build the transparent version first; that is when the neural network becomes a meaningful upgrade rather than a black box you cannot debug.
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?
Adding the immediate-win and immediate-block prechecks before the search, because those two moves matter more than any depth of general evaluation, and they are faster and more reliable than a deeper tree. After that, better move ordering, and then a VCF-style threat search is the next real jump in strength.
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-09-16. For the search techniques this article leaves out, see The Science of Gomoku.