cd /news/artificial-intelligence/how-i-built-a-browser-gomoku-game-wi… · home › topics › artificial-intelligence › article
[ARTICLE · art-166857] src=dev.to ↗ pub= topic=artificial-intelligence verified=true sentiment=↑ positive

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

A developer built a browser-based Gomoku (five-in-a-row) game featuring three AI difficulty levels and real online multiplayer. The AI uses pattern-based evaluation rather than full alpha-beta search, scoring moves by connected-stone counts and open ends across four directions and restricting candidates to empty cells within two squares of existing stones. Easy, medium and hard modes share the evaluation function but differ in mistake rate, top-candidate randomization and a one-ply lookahead.

read6 min views2 publishedNov 10, 2026

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:

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:

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:

// 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:

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:

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:

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

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:

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

It is part of a small browser games hub 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.

── more in #artificial-intelligence 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-i-built-a-browse…] indexed:0 read:6min 2026-11-10 · —