The source has a search AI

The AI in othello-web/lib/ai.ts reads several moves ahead with alpha-beta search. With many empty squares it reads 4 moves, with 12 or fewer it reads 6, and with 8 or fewer it reads to the end. Boards are evaluated by square weights, the number of available moves, corners, and the squares diagonally next to corners (X-squares). The Qt app's aiplayer.cpp works the same way.

If this AI is made to play the 13–0 sequence, on the 6th move it plays B5 instead of D6. It does not follow the recorded sequence.

A different AI was deployed

We unpacked the AI part of the page code that othello.mariago.kr currently serves. There is no search. It tries placing a disc once on each available square and picks the highest score.

  • Score = square weight×4 + discs on the board + (own discs − opponent discs)×3 − opponent's available moves×2
  • Add 40 points for a corner.

If this AI is made to play the 13–0 sequence, all four of White's moves match the records.

The web's last build was made 13 minutes before the search AI was saved. The site was never rebuilt after that, so the first AI stayed on it.

Move 8

The picture above shows the six squares White can play on move 8. On four of them, Black flips all of White's discs with the very next move. The one-move AI rated F4 highest at -12 points and chose it. Since it does not look at Black's next move, it cannot see the wipe-out. The search AI sees all four as losing moves and picks B6.

Pitting the AIs against each other

We played three AIs against each other, 100 games per pair. They alternated Black and White, and the first 4 moves were random so the same game would not repeat.

  • Search AI vs one-move AI: 100 to 0
  • Search AI vs random: 99 to 1
  • One-move AI vs random: 86 to 10, 4 draws

Black has 2 sequences that wipe out White within 11 moves against the one-move AI, and 0 against the search AI.

Results of 100 games between each pair of AIs and the number of wipe-out sequences
Results of 100 games between each pair of AIs and the number of wipe-out sequences

The actual code for this step

This is the one-move AI unpacked from the deployed page code. Only the shortened variable names were renamed for readability.

AI in the deployed buildts · 34 lines
const WEIGHTS = [
  [100, -20, 10, 5, 5, 10, -20, 100],
  [-20, -50, -2, -2, -2, -2, -50, -20],
  [10, -2, 1, 1, 1, 1, -2, 10],
  [5, -2, 1, 0, 0, 1, -2, 5],
  [5, -2, 1, 0, 0, 1, -2, 5],
  [10, -2, 1, 1, 1, 1, -2, 10],
  [-20, -50, -2, -2, -2, -2, -50, -20],
  [100, -20, 10, 5, 5, 10, -20, 100],
];

function suggestMove(board, who) {
  const moves = legalMoves(board, who);
  if (moves.length === 0) return null;
  const opp = opponent(who);
  let best = moves[0];
  let bestScore = -1e9;
  for (const mv of moves) {
    const res = playAt(board, mv.r, mv.c, who, "playing");
    if (!res.ok) continue;
    const mine = who === BLACK ? res.black : res.white;
    const theirs = who === BLACK ? res.white : res.black;
    let score = 4 * WEIGHTS[mv.r][mv.c];
    score += res.black + res.white;
    score += (mine - theirs) * 3;
    score -= 2 * legalMoves(res.board, opp).length;
    if ((mv.r === 0 || mv.r === 7) && (mv.c === 0 || mv.c === 7)) score += 40;
    if (score > bestScore) {
      bestScore = score;
      best = mv;
    }
  }
  return best;
}

This is the search AI in the source.

othello-web/lib/ai.tsts · 61 lines
function searchDepth(empties: number): number {
  if (empties <= 8) return empties;
  if (empties <= 12) return 6;
  if (empties <= 20) return 5;
  return 4;
}

// ...

function negamax(
  board: Stone[],
  who: Stone,
  depth: number,
  alpha: number,
  beta: number,
): SearchResult {
  const moves = legalMoves(board, who);
  const opp = opponent(who);

  if (moves.length === 0) {
    if (legalMoves(board, opp).length === 0) {
      const { black, white } = counts(board);
      const mine = who === BLACK ? black : white;
      const theirs = who === BLACK ? white : black;
      return { score: (mine - theirs) * 100_000, move: null };
    }
    const child = negamax(board, opp, depth - 1, -beta, -alpha);
    return { score: -child.score, move: null };
  }

  if (depth <= 0) {
    return { score: evaluate(board, who), move: null };
  }

  let bestScore = -Infinity;
  let bestMove: Point | null = null;
  for (const mv of orderMoves(moves)) {
    const res = playAt(board, mv.r, mv.c, who, "playing");
    if (!res.ok) continue;
    const child = negamax(res.board, opp, depth - 1, -beta, -alpha);
    const score = -child.score;
    if (score > bestScore) {
      bestScore = score;
      bestMove = mv;
    }
    if (score > alpha) alpha = score;
    if (alpha >= beta) break;
  }
  return { score: bestScore, move: bestMove };
}

/** 알파·베타 탐색. 초반은 위치·기동력, 종반은 돌 수를 중시. */
export function suggestMove(board: Stone[], who: Stone): Point | null {
  const moves = legalMoves(board, who);
  if (moves.length === 0) return null;
  if (moves.length === 1) return moves[0]!;

  const depth = searchDepth(emptyCount(board));
  const { move } = negamax(board, who, depth, -Infinity, Infinity);
  return move ?? moves[0]!;
}

The next post is "3. The VLM never played a move: Gemma 4 failed to load".