Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

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 problem

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[][]

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:

  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

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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.