DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Gomoku from scratch: one outward count from the last stone powers both the win check and the greedy AI

Gomoku is the kind of game that looks like it needs a heavy board-scanning routine to tell who won. It doesn't. When I built a complete two-player Gomoku — a 15×15 board, five-in-a-row detection in every direction, an undo stack, and an optional computer opponent — the whole thing came to about 120 lines of vanilla JavaScript, no images and no libraries. The trick is that almost everything falls out of one idea: after a stone lands, you only ever count outward from that one point. Here is how it fits together.

The board is 225 characters

There are no sprites and no stone objects. The entire position is fifteen strings, split into a 15×15 array of single characters: a dot is an empty point, b is a Black stone, w is White. Black always moves first. One helper swaps the side to move, and that is the whole data model.

const SIZE  = 15;
const START = Array.from({ length: SIZE }, () => ".".repeat(SIZE));
const other = c => c === "b" ? "w" : "b";      // swap the side to move
let board = START.map(row => row.split(""));    // 15×15 of "." / "b" / "w"
let turn  = "b";                                // Black moves first
let moves = [];                                 // history: {r,c,col}
Enter fullscreen mode Exit fullscreen mode

Four directions, not eight

A winning line can run four ways: across, down, and the two diagonals. You do not need all eight compass directions — when you later walk a line you go both forwards and backwards along the same axis, so [0,1] already covers left and right. One inB bounds check keeps every coordinate on the grid so nothing else has to think about the edges.

const inB  = (r,c) => r>=0 && r<SIZE && c>=0 && c<SIZE;
const DIRS = [[0,1],[1,0],[1,1],[1,-1]];   // →  ↓  ↘  ↙
Enter fullscreen mode Exit fullscreen mode

Count outward from the last stone

This is the heart of the game. Only lines passing through the point that just changed could possibly have completed — so you never rescan the board. From the new stone, step one direction and keep counting while the colour stays the same; that is the length of one arm. A full line through the point is the stone itself, plus the arm going one way, plus the arm going the other.

function runLength(bd, r, c, dr, dc, col){
  let n = 0, rr = r + dr, cc = c + dc;
  while (inB(rr,cc) && bd[rr][cc] === col){ n++; rr += dr; cc += dc; }
  return n;                                 // same-colour stones one way
}
// line through (r,c) = 1 + runLength(+dir) + runLength(-dir)
Enter fullscreen mode Exit fullscreen mode

The four-direction win scan

Run that outward count once per direction. Walk forward collecting matching stones, then walk backward prepending the rest, and you are holding the entire unbroken line through the point — in order. If any of the four lines is five stones or longer, that colour has won. Because I kept the coordinates rather than just a count, the function hands back the exact line to light up green on the board.

function winLineFrom(bd, r, c){
  const col = bd[r][c]; if (col === ".") return null;
  for (const [dr,dc] of DIRS){
    const line = [[r,c]];
    let rr=r+dr, cc=c+dc;                                       // forward
    while (inB(rr,cc) && bd[rr][cc]===col){ line.push([rr,cc]); rr+=dr; cc+=dc; }
    rr=r-dr; cc=c-dc;                                           // backward
    while (inB(rr,cc) && bd[rr][cc]===col){ line.unshift([rr,cc]); rr-=dr; cc-=dc; }
    if (line.length >= 5) return line;                         // five — win!
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

The AI is the same count, scored twice

The opponent is where the reuse pays off. To value a point for a colour, I run the very same outward count in each direction and measure the run it would join and how many open ends that run still has — an open end is what lets a line grow to five. A tiny table turns (length, open-ends) into a score: a completed five is enormous, an open four nearly as good, an open three a real threat.

function patternScore(count, open){
  if (count >= 5)  return 1e7;
  if (count === 4) return open === 2 ? 1e6 : open ? 1e4 : 0;   // open four
  if (count === 3) return open === 2 ? 1e3 : open ? 1e2 : 0;   // open three
  if (count === 2) return open === 2 ? 1e2 : open ? 10  : 0;
  return open ? 1 : 0;
}
Enter fullscreen mode Exit fullscreen mode

The whole opponent is then one greedy loop over empty points near existing stones. Each candidate is valued twice: what it builds for the AI, plus what it denies you if the AI grabs it first. Adding the two — with a hair more weight on attack — means a single move that both extends the machine's threat and blocks yours floats to the top, and its own winning five outscores everything.

const s = scorePoint(board,r,c,ai) * 1.05    // my own threat
        + scorePoint(board,r,c,human);       // + the block I deny you
Enter fullscreen mode Exit fullscreen mode

The lesson underneath is the same one chess taught me: keep the smallest possible source of truth — 225 characters — and let the win check, the highlight, and the entire AI fall out as reads over it. Undo is just a pop off the move stack (that lifts the computer's reply too), and rendering is a loop that drops a disc on each occupied point.

Play a full game, or flip on the computer:
https://dev48v.infy.uk/game/day40-gomoku.html

Top comments (0)