The winning shape

○_○○○, with one empty cell inside, becomes five as soon as that cell is filled. In Gomoku terms this is a broken four. If one move creates this broken four together with a solid three (○○○) on another line, you win. The opponent must block the four first, and meanwhile you turn the three into an open four, which cannot be stopped. This is a four-three.

People find this spot easily. But the current AI does not play it even when it can make this shape with its own stones.

A four-three the AI missed. Playing on the dotted spot makes a horizontal broken four and a vertical solid three, but the AI played at the left end of the line
A four-three the AI missed. Playing on the dotted spot makes a horizontal broken four and a vertical solid three, but the AI played at the left end of the line

In real games

We replayed 24 web games and looked for moments when the AI (White) could have made a four-three in one move. There were 7 such moments, and the AI played the spot only once. It missed both chances to win through a broken four.

People, on the other hand, won with the same shape. Of the 22 games people won, 11 ended with a four-three.

How the 22 games people won ended. Open four 9, four-three (solid three) 6, four-three (broken three) 5, four-four 2
How the 22 games people won ended. Open four 9, four-three (solid three) 6, four-three (broken three) 5, four-four 2

Below is one of those games. When Black played on the dotted spot, a diagonal broken four (●●_●●) and a horizontal broken three (●●_●) appeared at the same time.

A four-three in a real game. Black's 23rd move creates a four and a broken three at the same time
A four-three in a real game. Black's 23rd move creates a four and a broken three at the same time

Testing the local AI

We ported the web's local AI code as is and tested it on a 15×15 board, randomly changing the orientation. It found its own four-three spot 29% of the time with a solid four (○○○○) plus a three, and 27% with a broken four (○_○○○) plus a three. It blocked the spot where the opponent could make a four-three on the next move only 33% of the time. Blocking a single three succeeded 67% of the time for a solid three and 100% for a broken three.

Local AI tests. Finding own four-three: solid four 29%, broken four 27%; blocking the opponent's four-three spot 33%; blocking a solid three 67%; blocking a broken three 100%
Local AI tests. Finding own four-three: solid four 29%, broken four 27%; blocking the opponent's four-three spot 33%; blocking a solid three 67%; blocking a broken three 100%

Why it cannot see it

The local AI scores each empty cell separately in four directions and adds them up. But for each direction, it counts five cells only forward from that cell, such as to the right or downward, and never looks backward. If the stones of the four and the three lie to the left of and above the four-three spot, as in the picture above, not a single stone is visible from that spot. Instead, looking right from the left end of the line shows three stones, so it picks that spot.

It also just adds up the score of each line and never checks separately whether two lines meet at one point to form a four-three. The code that looks for winning moves only checks moves that make five immediately. So it plays a four-three only when the direction happens to line up.

The forbidden-move check is done separately by the engine, and the engine correctly catches double-threes that include a broken three. The rules are being seen correctly; it is the player's judgement that falls short.

The actual code for this step

This is the part where the local AI scores a single empty cell. You can see that it counts only the five cells ahead.

omook-web/lib/ai.tstypescript · 38 lines
function linePotential(
  board: Stone[],
  r: number,
  c: number,
  dr: number,
  dc: number,
  color: Stone,
  size: number,
): number {
  let streak = 0;
  let blockedNear = false;
  const rr0 = r - dr;
  const cc0 = c - dc;
  if (inBounds(rr0, cc0, size)) {
    const oc = board[idx(rr0, cc0, size)];
    if (oc !== EMPTY && oc !== color) blockedNear = true;
  }

  let rr = r;
  let cc = c;
  for (let k = 0; k < 5 && inBounds(rr, cc, size); k++, rr += dr, cc += dc) {
    const v = board[idx(rr, cc, size)];
    if (v === color) streak++;
    else if (v === EMPTY) {
      /* open */
    } else break;
  }
  let score = streak * streak * 120;
  if (streak >= 4) score += 50000;
  if (blockedNear && streak >= 4) score = Math.floor(score / 8);
  return score;
}

function scorePlacement(board: Stone[], r: number, c: number, who: Stone, size: number): number {
  let s = 0;
  for (const [dr, dc] of DIRS) s += linePotential(board, r, c, dr, dc, who, size);
  return s;
}

The next post is "4. A plan for the VLM to learn the heuristic, and for the heuristic to become the teacher again".