DEV Community

Cover image for How I Built a Browser Gomoku Game with AI and Multiplayer Support
许鑫
许鑫

Posted on

How I Built a Browser Gomoku Game with AI and Multiplayer Support

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:

  1. The massive game tree, which makes naive search too slow.
  2. An AI that feels fair instead of either braindead or unbeatable.
  3. 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:

Play Gomoku in the browser

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[][]
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Easy

Easy looks at offense plus a little defense, but deliberately makes mistakes:

const missChance = 0.25 // sometimes fails to block
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

A joiner gets a 6-character room code from a charset that avoids ambiguous characters (no 0/O, 1/I):

const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
Enter fullscreen mode Exit fullscreen mode

The golden rule: the server is the source of truth

The client never decides the outcome. When a player sends make_move, the server:

  1. Validates it's actually their turn.
  2. Validates the cell is empty.
  3. Places the stone.
  4. Checks for a win or draw.
  5. Switches the current player.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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,
}))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (2)

Collapse
 
dhaval109 profile image
Dhaval Patel •

Nice write-up. If players start taking it seriously: on a free 15×15 board Gomoku is a proven first-player win, which is why tournaments add opening rules like Swap2. Even casually, alternating who plays Black on each rematch helps the fairness you were aiming for. Did Black win noticeably more in your test games?

Collapse
 
_8b9f989e3bd077f5726863 profile image
许鑫 •

Thanks — and you're right on both counts. Free-style 15×15 Gomoku being a solved first-player win is exactly why serious play adds opening protocols like Swap2, and I appreciate you pointing it out.

Honest answer on the data: I don't actually have a win-rate number worth quoting. I didn't instrument online results, so any "Black won X% of the time" claim from me would be anecdotal, not measured — I'd rather not invent one. Mechanically, though, Black does carry the first-move edge in what I built, and your comment exposes a real gap: in the current implementation the room host is always Black, Black always moves first, and a rematch resets the board back to Black-to-move without swapping colors. So across a rematch series the same player keeps the advantage — which is pretty much the opposite of the fairness I was aiming for.

Your suggestion is the right low-cost fix: alternate which player is Black on each rematch, so each side starts roughly half the games. Swap2 would be more "correct," but for a casual hub the simple alternation captures most of the fairness benefit without bolting on a full bidding flow. In AI mode the player can already choose to play White, but online/rematch is exactly where this matters most.

I'm going to add color-swapping on rematch — thanks for the nudge.🫡