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

> Source: <https://dev.to/_8b9f989e3bd077f5726863/how-i-built-a-browser-gomoku-game-with-ai-and-multiplayer-support-12lh>
> Published: 2026-11-10 10:17:03+00:00

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:

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 is a classic 15×15 grid:

``` js
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.

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.

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:

``` js
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.

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.

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.

Before scoring anything, every difficulty follows two non-negotiable rules:

``` js
// 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 looks at offense plus a little defense, but deliberately makes mistakes:

``` js
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 weights offense vs. defense and picks randomly among the top few candidates, which adds variety without being unfair:

``` js
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 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:

``` js
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.

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.

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`):

``` js
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
```

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

This prevents cheating and keeps both boards perfectly in sync.

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.

Without a database, memory has to be managed carefully. A periodic job removes rooms that are:

``` js
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.

A few smaller decisions that made the experience feel polished:

If you want to test the AI or play a friend, the game is live here:

[Play Gomoku — play vs AI, locally, or online](https://ecjtu.cloud/tool/gomoku?utm_source=devto&utm_medium=article&utm_campaign=gomoku)

It is part of a small browser [games hub](https://ecjtu.cloud/games?utm_source=devto&utm_medium=article&utm_campaign=gomoku) that also includes Chinese chess, mahjong, and a few quick arcade and puzzle games.

Some ideas I'm considering:

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.
