Gomoku (also known as "five in a row") looks simple on the surface: place stones on a grid, and the first player to line up five in a row wins.
But once you try to build it properly, you run into three different problems:
- The massive game tree, which makes naive search too slow.
- An AI that feels fair instead of either braindead or unbeatable.
- Multiplayer over a lossy connection, without a database and without forcing users to sign up.
This post walks through how I built a browser Gomoku game with three AI difficulties and real online multiplayer. You can try the final result here:
The board and rules
The board is a classic 15×15 grid:
export const BOARD_SIZE = 15
// 0 = empty, 1 = black, 2 = white
export type CellValue = 0 | 1 | 2
export type Board = CellValue[][]
Players take turns placing stones. Black moves first. A player wins by forming an unbroken line of five (or more) stones horizontally, vertically, or diagonally. If the board fills up with no winner, it is a draw.
Checking for a win after every move only requires scanning four directions from the last placed stone, which is cheap. The hard part is the AI.
Building the Gomoku AI
Why not a full game tree search?
A full search over a 15×15 board is computationally hopeless without heavy pruning. Even with alpha-beta pruning, a branching factor of 200+ quickly becomes unmanageable in the browser.
Instead, I used a pattern-based evaluation approach. It plays fast, scales to thousands of candidate positions, and — importantly — feels human.
Scoring patterns
For every candidate move, I look in four directions and count how many stones are connected and how many ends are open. That gives a score per direction:
const SCORE = {
FIVE: 1_000_000,
OPEN_FOUR: 100_000,
FOUR: 10_000, // "closed four" (one end blocked)
OPEN_THREE: 8_000,
THREE: 800,
OPEN_TWO: 500,
TWO: 100,
ONE: 10,
}
function directionScore(count, openEnds) {
if (count >= 5) return SCORE.FIVE
if (openEnds === 0) return 0 // blocked on both sides
switch (count) {
case 4: return openEnds === 2 ? SCORE.OPEN_FOUR : SCORE.FOUR
case 3: return openEnds === 2 ? SCORE.OPEN_THREE : SCORE.THREE
case 2: return openEnds === 2 ? SCORE.OPEN_TWO : SCORE.TWO
case 1: return SCORE.ONE
default: return 0
}
}
A move is then scored by summing its four directions, with extra bonuses for compound patterns like double open threes or four-three combinations, which are near-winning threats.
Cutting the search space
Instead of evaluating all 225 empty cells, the AI only considers empty cells within two squares of an existing stone. On an empty board it simply plays the center.
// Only empty cells near existing stones are candidates.
// This dramatically reduces the work per move.
const candidates = getCandidates(board)
Early in the game this collapses the search from ~225 cells to a few dozen, and it scales gracefully as the board fills up.
Making the AI feel fair: three difficulties
A single AI is either too hard or too boring. So I split it into three personalities that share the same evaluation function but behave very differently.
The priority flow
Before scoring anything, every difficulty follows two non-negotiable rules:
// 1. If I can win right now, win.
for (const p of candidates) {
if (createsFive(board, p.row, p.col, player)) return p
}
// 2. If my opponent can win next move, block it.
const losingMoves = candidates.filter(p => createsFive(board, p.row, p.col, opponent))
// ... handle blocking
Easy
Easy looks at offense plus a little defense, but deliberately makes mistakes:
const missChance = 0.25 // sometimes fails to block
and occasionally picks a random move from a broader pool, so beginners get chances to win.
Medium
Medium weights offense vs. defense and picks randomly among the top few candidates, which adds variety without being unfair:
const score = evaluatePosition(board, p.row, p.col, player)
+ evaluatePosition(board, p.row, p.col, opponent) * 1.1
// pick randomly among the top 3
Hard
Hard adds a one-ply lookahead: for each of its top candidates, it simulates placing its stone, then asks "what is the opponent's best single-move threat now?" and penalizes moves that leave the opponent a strong reply:
board[cand.p.row][cand.p.col] = player
let oppBest = 0
for (const q of candidates) {
const t = evaluatePosition(board, q.row, q.col, opponent)
if (t > oppBest) oppBest = t
}
board[cand.p.row][cand.p.col] = 0
const value = cand.ai * 1.15 + cand.opp * 1.0 - oppBest * 0.9
It only does this for the top ~16 candidates to keep the turn fast, but it is enough to avoid blunders and punish open threes.
Online multiplayer without a database
The multiplayer mode was the part I most wanted to keep frictionless: no accounts, no lobbies to sign into, just share a room code.
It runs on a WebSocket server (h3's defineWebSocketHandler) with an in-memory room store.
Room model
Each room holds the board, whose turn it is, the status, and up to two players:
interface Room {
roomCode: string
players: RoomPlayer[]
board: number[][]
currentPlayer: Player
gameStatus: 'waiting' | 'playing' | 'gameover'
winner: GameResult
rematchRequests: Set<string>
createdAt: number
}
A joiner gets a 6-character room code from a charset that avoids ambiguous characters (no 0/O, 1/I):
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
The golden rule: the server is the source of truth
The client never decides the outcome. When a player sends make_move, the server:
- Validates it's actually their turn.
- Validates the cell is empty.
- Places the stone.
- Checks for a win or draw.
- Switches the current player.
- Broadcasts the new board to both players.
This prevents cheating and keeps both boards perfectly in sync.
Surviving disconnects: token-based reconnect
The scariest part of a socket game is the flaky connection. Mobile browsers background tabs, laptops sleep, wifi drops. I did not want a single hiccup to lose the match.
The solution: identity via token, not via socket.
When a player creates or joins a room, the server issues them a random playerToken. The client stores { roomCode, playerToken } in sessionStorage. Every player also tracks a lastSeen timestamp.
When the socket closes, the server does not delete the room — it just marks the player as offline:
player.peer = null
player.online = false
player.lastSeen = Date.now()
// keep the room alive so the player can come back
On reconnect, the client sends reconnect with its token, and the server rebinds the new socket to the old player, then sends the full room state back:
peer.send(JSON.stringify({
type: 'room_rejoined',
roomCode,
myColor: player.color,
board: room.board,
currentPlayer: room.currentPlayer,
gameStatus: room.gameStatus,
opponentName,
opponentOnline,
}))
The opponent gets an opponent_reconnected (or opponent_left) notification, so they always know what's happening.
Cleanup
Without a database, memory has to be managed carefully. A periodic job removes rooms that are:
- Older than 24 hours, or
- Fully offline for more than 5 minutes.
const OFFLINE_TIMEOUT_MS = 5 * 60 * 1000
const ROOM_MAX_MS = 24 * 60 * 60 * 1000
So abandoned games don't leak forever, but a short disconnect never kills a live match.
Other details worth calling out
A few smaller decisions that made the experience feel polished:
- Rematch flow. Both players must request a rematch; once both agree, the board resets automatically.
- Draw detection. Board-full checking runs alongside win detection so stalemates resolve cleanly.
- Mode clarity. The hub labels each game as single-player, AI, local, or online, so players know what they're getting before they start.
- Local, AI, and online all in one component tree, sharing the same board rendering and move logic.
Try it
If you want to test the AI or play a friend, the game is live here:
Play Gomoku — play vs AI, locally, or online
It is part of a small browser games hub that also includes Chinese chess, mahjong, and a few quick arcade and puzzle games.
What I'd do next
Some ideas I'm considering:
- A trained model or deeper search for a "brutal" difficulty tier.
- A spectator mode for online rooms.
- Persistent Elo ratings once players opt into lightweight accounts.
If you've built a browser-based board game, I'd love to hear how you handled the AI evaluation and the reconnection logic — those were the two hardest parts for me.
Top comments (0)