{"slug": "how-i-built-a-browser-gomoku-game-with-ai-and-multiplayer-support", "title": "How I Built a Browser Gomoku Game with AI and Multiplayer Support", "summary": "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.", "body_md": "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.\n\nBut once you try to build it properly, you run into three different problems:\n\nThis 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:\n\nThe board is a classic 15×15 grid:\n\n``` js\nexport const BOARD_SIZE = 15\n\n// 0 = empty, 1 = black, 2 = white\nexport type CellValue = 0 | 1 | 2\nexport type Board = CellValue[][]\n```\n\nPlayers 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.\n\nChecking 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.\n\nA 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.\n\nInstead, I used a **pattern-based evaluation** approach. It plays fast, scales to thousands of candidate positions, and — importantly — feels human.\n\nFor 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:\n\n``` js\nconst SCORE = {\n  FIVE: 1_000_000,\n  OPEN_FOUR: 100_000,\n  FOUR: 10_000,      // \"closed four\" (one end blocked)\n  OPEN_THREE: 8_000,\n  THREE: 800,\n  OPEN_TWO: 500,\n  TWO: 100,\n  ONE: 10,\n}\n\nfunction directionScore(count, openEnds) {\n  if (count >= 5) return SCORE.FIVE\n  if (openEnds === 0) return 0        // blocked on both sides\n  switch (count) {\n    case 4: return openEnds === 2 ? SCORE.OPEN_FOUR : SCORE.FOUR\n    case 3: return openEnds === 2 ? SCORE.OPEN_THREE : SCORE.THREE\n    case 2: return openEnds === 2 ? SCORE.OPEN_TWO : SCORE.TWO\n    case 1: return SCORE.ONE\n    default: return 0\n  }\n}\n```\n\nA 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.\n\nInstead 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.\n\n```\n// Only empty cells near existing stones are candidates.\n// This dramatically reduces the work per move.\nconst candidates = getCandidates(board)\n```\n\nEarly in the game this collapses the search from ~225 cells to a few dozen, and it scales gracefully as the board fills up.\n\nA 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.\n\nBefore scoring anything, every difficulty follows two non-negotiable rules:\n\n``` js\n// 1. If I can win right now, win.\nfor (const p of candidates) {\n  if (createsFive(board, p.row, p.col, player)) return p\n}\n\n// 2. If my opponent can win next move, block it.\nconst losingMoves = candidates.filter(p => createsFive(board, p.row, p.col, opponent))\n// ... handle blocking\n```\n\nEasy looks at offense plus a little defense, but deliberately makes mistakes:\n\n``` js\nconst missChance = 0.25 // sometimes fails to block\n```\n\nand occasionally picks a random move from a broader pool, so beginners get chances to win.\n\nMedium weights offense vs. defense and picks randomly among the top few candidates, which adds variety without being unfair:\n\n``` js\nconst score = evaluatePosition(board, p.row, p.col, player)\n            + evaluatePosition(board, p.row, p.col, opponent) * 1.1\n\n// pick randomly among the top 3\n```\n\nHard 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:\n\n``` js\nboard[cand.p.row][cand.p.col] = player\nlet oppBest = 0\nfor (const q of candidates) {\n  const t = evaluatePosition(board, q.row, q.col, opponent)\n  if (t > oppBest) oppBest = t\n}\nboard[cand.p.row][cand.p.col] = 0\n\nconst value = cand.ai * 1.15 + cand.opp * 1.0 - oppBest * 0.9\n```\n\nIt only does this for the top ~16 candidates to keep the turn fast, but it is enough to avoid blunders and punish open threes.\n\nThe multiplayer mode was the part I most wanted to keep frictionless: no accounts, no lobbies to sign into, just share a room code.\n\nIt runs on a WebSocket server (h3's `defineWebSocketHandler`) with an in-memory room store.\n\nEach room holds the board, whose turn it is, the status, and up to two players:\n\n```\ninterface Room {\n  roomCode: string\n  players: RoomPlayer[]\n  board: number[][]\n  currentPlayer: Player\n  gameStatus: 'waiting' | 'playing' | 'gameover'\n  winner: GameResult\n  rematchRequests: Set<string>\n  createdAt: number\n}\n```\n\nA joiner gets a 6-character room code from a charset that avoids ambiguous characters (no `0/O`, `1/I`):\n\n``` js\nconst chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'\n```\n\nThe client never decides the outcome. When a player sends `make_move`, the server:\n\nThis prevents cheating and keeps both boards perfectly in sync.\n\nThe 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.\n\nThe solution: **identity via token, not via socket**.\n\nWhen 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.\n\nWhen the socket closes, the server does **not** delete the room — it just marks the player as offline:\n\n```\nplayer.peer = null\nplayer.online = false\nplayer.lastSeen = Date.now()\n// keep the room alive so the player can come back\n```\n\nOn 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:\n\n```\npeer.send(JSON.stringify({\n  type: 'room_rejoined',\n  roomCode,\n  myColor: player.color,\n  board: room.board,\n  currentPlayer: room.currentPlayer,\n  gameStatus: room.gameStatus,\n  opponentName,\n  opponentOnline,\n}))\n```\n\nThe opponent gets an `opponent_reconnected` (or `opponent_left`) notification, so they always know what's happening.\n\nWithout a database, memory has to be managed carefully. A periodic job removes rooms that are:\n\n``` js\nconst OFFLINE_TIMEOUT_MS = 5 * 60 * 1000\nconst ROOM_MAX_MS = 24 * 60 * 60 * 1000\n```\n\nSo abandoned games don't leak forever, but a short disconnect never kills a live match.\n\nA few smaller decisions that made the experience feel polished:\n\nIf you want to test the AI or play a friend, the game is live here:\n\n[Play Gomoku — play vs AI, locally, or online](https://ecjtu.cloud/tool/gomoku?utm_source=devto&utm_medium=article&utm_campaign=gomoku)\n\nIt 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.\n\nSome ideas I'm considering:\n\nIf 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.", "url": "https://wpnews.pro/news/how-i-built-a-browser-gomoku-game-with-ai-and-multiplayer-support", "canonical_source": "https://dev.to/_8b9f989e3bd077f5726863/how-i-built-a-browser-gomoku-game-with-ai-and-multiplayer-support-12lh", "published_at": "2026-11-10 10:17:03+00:00", "updated_at": "2026-11-10 10:22:24.166890+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "ai-tools", "developer-tools"], "entities": [], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-i-built-a-browser-gomoku-game-with-ai-and-multiplayer-support", "markdown": "https://wpnews.pro/news/how-i-built-a-browser-gomoku-game-with-ai-and-multiplayer-support.md", "text": "https://wpnews.pro/news/how-i-built-a-browser-gomoku-game-with-ai-and-multiplayer-support.txt", "jsonld": "https://wpnews.pro/news/how-i-built-a-browser-gomoku-game-with-ai-and-multiplayer-support.jsonld"}}