<p class="hint">You are <b>black</b> and move first. White (the engine) blocks your strongest threat each turn. Aim for a <b>double threat</b>: one stone that makes <b>two</b> open fours at once — white can block only one, and your five is unstoppable.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Your move. Click an empty point.</div>
<div class="btns">
<button id="hint" type="button">Hint</button>
<button id="reset" type="button" class="ghost">New game</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.board { display: grid; grid-template-columns: repeat(9, 34px); gap: 0;
background: #e7c993; border: 2px solid #b9924f; width: max-content;
padding: 4px; border-radius: 6px; margin: .4rem 0; }
.pt { width: 34px; height: 34px; position: relative; cursor: pointer; }
.pt::before, .pt::after { content: ""; position: absolute; background: #9c7a3c; }
.pt::before { left: 50%; top: 0; width: 1px; height: 100%; transform: translateX(-.5px); }
.pt::after { top: 50%; left: 0; height: 1px; width: 100%; transform: translateY(-.5px); }
.stone { position: absolute; inset: 4px; border-radius: 50%; z-index: 2; }
.stone.b { background: radial-gradient(circle at 35% 30%, #555, #000); }
.stone.w { background: radial-gradient(circle at 35% 30%, #fff, #c2c2c2); border: 1px solid #999; }
.pt.win .stone { box-shadow: 0 0 0 3px #e63946; }
.pt.tip::after, .pt.tip::before { background: #137a3a; }
.pt.tip { outline: 2px dashed #137a3a; outline-offset: -3px; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
// Gomoku (free-style, five in a row) on a small 9x9 board. You play black and move first. Free-style Gomoku on 15x15 is SOLVED: the first player wins with perfect play (Allis, 1993). The engine of that proof is the DOUBLE THREAT — one move that creates two "open fours" at once. White can block only one, so black's five becomes unstoppable. Here white defends greedily (blocks black's most dangerous line) so you can feel how a fork beats any single reply.
const N = 9;
const EMPTY = 0, BLACK = 1, WHITE = 2;
const board = new Array(N * N).fill(EMPTY);
const DIRS = [[1, 0], [0, 1], [1, 1], [1, -1]]; // -, |, \, /
const idx = (r, c) => r * N + c;
const inB = (r, c) => r >= 0 && r < N && c >= 0 && c < N;
let over = false;
// Count stones of `p` in a maximal run through (r,c) along dir, plus whether the two ends are open. Used to recognise threats and wins.
function lineInfo(r, c, p, dr, dc) {
let len = 1, openA = false, openB = false;
let rr = r + dr, cc = c + dc;
while (inB(rr, cc) && board[idx(rr, cc)] === p) { len++; rr += dr; cc += dc; }
openA = inB(rr, cc) && board[idx(rr, cc)] === EMPTY;
rr = r - dr; cc = c - dc;
while (inB(rr, cc) && board[idx(rr, cc)] === p) { len++; rr -= dr; cc -= dc; }
openB = inB(rr, cc) && board[idx(rr, cc)] === EMPTY;
return { len, ends: (openA ? 1 : 0) + (openB ? 1 : 0) };
}
// Five (or more) in a row anywhere through the point just played.
function winningAt(r, c, p) {
for (const [dr, dc] of DIRS) if (lineInfo(r, c, p, dr, dc).len >= 5) return true;
return false;
}
// How many "open fours" would player p own after playing (r,c)? An open four is a four-in-a-row with an empty end — a one-move-from-five threat. Two of them from a single stone is the unstoppable double threat (a fork).
function openFoursAfter(r, c, p) {
board[idx(r, c)] = p;
let f = 0;
for (const [dr, dc] of DIRS) {
const info = lineInfo(r, c, p, dr, dc);
if (info.len === 4 && info.ends >= 1) f++;
if (info.len >= 5) f += 2; // already a win counts as overwhelming
}
board[idx(r, c)] = EMPTY;
return f;
}
function emptyCells() {
const out = [];
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++)
if (board[idx(r, c)] === EMPTY) out.push([r, c]);
return out;
}
// Find a black move that immediately wins or builds a double threat (fork).
function findFork(p) {
let best = null;
for (const [r, c] of emptyCells()) {
if (openFoursAfter(r, c, p) >= 2) { best = [r, c]; break; }
}
return best;
}
// White's greedy defense: win if it can, else block black's most dangerous line.
function whiteMove() {
for (const [r, c] of emptyCells()) if (winInfoAt(r, c, WHITE)) return [r, c];
let best = null, bestScore = -1;
for (const [r, c] of emptyCells()) {
let s = 0;
board[idx(r, c)] = BLACK; // would this point have been good for black?
for (const [dr, dc] of DIRS) {
const info = lineInfo(r, c, BLACK, dr, dc);
if (info.len >= 5) s = Math.max(s, 1000);
else if (info.len === 4 && info.ends >= 1) s = Math.max(s, 100 + info.ends);
else if (info.len === 3 && info.ends === 2) s = Math.max(s, 30);
else s = Math.max(s, info.len * 2 + info.ends);
}
board[idx(r, c)] = EMPTY;
if (s > bestScore) { bestScore = s; best = [r, c]; }
}
return best;
}
function winInfoAt(r, c, p) {
board[idx(r, c)] = p;
const w = winningAt(r, c, p);
board[idx(r, c)] = EMPTY;
return w;
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + (cls || '');
}
function markWin(r, c, p) {
for (const [dr, dc] of DIRS) {
if (lineInfo(r, c, p, dr, dc).len >= 5) {
// walk to one end, then mark the run
let rr = r, cc = c;
while (inB(rr - dr, cc - dc) && board[idx(rr - dr, cc - dc)] === p) { rr -= dr; cc -= dc; }
while (inB(rr, cc) && board[idx(rr, cc)] === p) {
const el = document.querySelector('.pt[data-i="' + idx(rr, cc) + '"]');
if (el) el.classList.add('win');
rr += dr; cc += dc;
}
return;
}
}
}
function render() {
const b = document.getElementById('board'); b.innerHTML = '';
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
const d = document.createElement('div');
d.className = 'pt'; d.dataset.i = idx(r, c);
const v = board[idx(r, c)];
if (v !== EMPTY) {
const s = document.createElement('div');
s.className = 'stone ' + (v === BLACK ? 'b' : 'w');
d.appendChild(s);
} else {
d.addEventListener('click', () => play(r, c));
}
b.appendChild(d);
}
}
function play(r, c) {
if (over || board[idx(r, c)] !== EMPTY) return;
const fork = openFoursAfter(r, c, BLACK) >= 2;
board[idx(r, c)] = BLACK;
render();
if (winningAt(r, c, BLACK)) { markWin(r, c, BLACK); over = true; return setStatus('Five in a row — you win!', 'ok'); }
if (fork) { setStatus('Double threat! Two open fours — white can block only one.', 'ok');
console.log('Fork played at', r, c, '— white cannot stop both fours.'); }
else setStatus('White is thinking...', '');
setTimeout(() => {
if (over) return;
const wm = whiteMove();
if (!wm) { over = true; return setStatus('Board full — draw.', ''); }
board[idx(wm[0], wm[1])] = WHITE;
render();
if (winningAt(wm[0], wm[1], WHITE)) { markWin(wm[0], wm[1], WHITE); over = true; return setStatus('White made five. Try again — build a fork.', 'bad'); }
if (fork) setStatus('White blocked one four — the other is still open. Complete your five!', 'ok');
else setStatus('Your move. Click an empty point.', '');
}, 250);
}
function hint() {
if (over) return;
document.querySelectorAll('.pt.tip').forEach(e => e.classList.remove('tip'));
const f = findFork(BLACK);
if (f) {
const el = document.querySelector('.pt[data-i="' + idx(f[0], f[1]) + '"]');
if (el) el.classList.add('tip');
setStatus('Fork available: this point opens two fours at once.', 'ok');
} else {
setStatus('No fork yet — build two open threes that share a point first.', '');
}
}
function reset() {
board.fill(EMPTY); over = false; render();
setStatus('Your move. Click an empty point.', '');
}
document.getElementById('hint').addEventListener('click', hint);
document.getElementById('reset').addEventListener('click', reset);
render();
<p class="hint">You are <b>black</b> and move first. White (the engine) blocks your strongest threat each turn. Aim for a <b>double threat</b>: one stone that makes <b>two</b> open fours at once — white can block only one, and your five is unstoppable.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Your move. Click an empty point.</div>
<div class="btns">
<button id="hint" type="button">Hint</button>
<button id="reset" type="button" class="ghost">New game</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.board { display: grid; grid-template-columns: repeat(9, 34px); gap: 0;
background: #e7c993; border: 2px solid #b9924f; width: max-content;
padding: 4px; border-radius: 6px; margin: .4rem 0; }
.pt { width: 34px; height: 34px; position: relative; cursor: pointer; }
.pt::before, .pt::after { content: ""; position: absolute; background: #9c7a3c; }
.pt::before { left: 50%; top: 0; width: 1px; height: 100%; transform: translateX(-.5px); }
.pt::after { top: 50%; left: 0; height: 1px; width: 100%; transform: translateY(-.5px); }
.stone { position: absolute; inset: 4px; border-radius: 50%; z-index: 2; }
.stone.b { background: radial-gradient(circle at 35% 30%, #555, #000); }
.stone.w { background: radial-gradient(circle at 35% 30%, #fff, #c2c2c2); border: 1px solid #999; }
.pt.win .stone { box-shadow: 0 0 0 3px #e63946; }
.pt.tip::after, .pt.tip::before { background: #137a3a; }
.pt.tip { outline: 2px dashed #137a3a; outline-offset: -3px; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
// Gomoku (free-style, five in a row) on a small 9x9 board. You play black and move first. Free-style Gomoku on 15x15 is SOLVED: the first player wins with perfect play (Allis, 1993). The engine of that proof is the DOUBLE THREAT — one move that creates two "open fours" at once. White can block only one, so black's five becomes unstoppable. Here white defends greedily (blocks black's most dangerous line) so you can feel how a fork beats any single reply.
const N = 9;
const EMPTY = 0, BLACK = 1, WHITE = 2;
const board = new Array(N * N).fill(EMPTY);
const DIRS = [[1, 0], [0, 1], [1, 1], [1, -1]]; // -, |, \, /
const idx = (r, c) => r * N + c;
const inB = (r, c) => r >= 0 && r < N && c >= 0 && c < N;
let over = false;
// Count stones of `p` in a maximal run through (r,c) along dir, plus whether the two ends are open. Used to recognise threats and wins.
function lineInfo(r, c, p, dr, dc) {
let len = 1, openA = false, openB = false;
let rr = r + dr, cc = c + dc;
while (inB(rr, cc) && board[idx(rr, cc)] === p) { len++; rr += dr; cc += dc; }
openA = inB(rr, cc) && board[idx(rr, cc)] === EMPTY;
rr = r - dr; cc = c - dc;
while (inB(rr, cc) && board[idx(rr, cc)] === p) { len++; rr -= dr; cc -= dc; }
openB = inB(rr, cc) && board[idx(rr, cc)] === EMPTY;
return { len, ends: (openA ? 1 : 0) + (openB ? 1 : 0) };
}
// Five (or more) in a row anywhere through the point just played.
function winningAt(r, c, p) {
for (const [dr, dc] of DIRS) if (lineInfo(r, c, p, dr, dc).len >= 5) return true;
return false;
}
// How many "open fours" would player p own after playing (r,c)? An open four is a four-in-a-row with an empty end — a one-move-from-five threat. Two of them from a single stone is the unstoppable double threat (a fork).
function openFoursAfter(r, c, p) {
board[idx(r, c)] = p;
let f = 0;
for (const [dr, dc] of DIRS) {
const info = lineInfo(r, c, p, dr, dc);
if (info.len === 4 && info.ends >= 1) f++;
if (info.len >= 5) f += 2; // already a win counts as overwhelming
}
board[idx(r, c)] = EMPTY;
return f;
}
function emptyCells() {
const out = [];
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++)
if (board[idx(r, c)] === EMPTY) out.push([r, c]);
return out;
}
// Find a black move that immediately wins or builds a double threat (fork).
function findFork(p) {
let best = null;
for (const [r, c] of emptyCells()) {
if (openFoursAfter(r, c, p) >= 2) { best = [r, c]; break; }
}
return best;
}
// White's greedy defense: win if it can, else block black's most dangerous line.
function whiteMove() {
for (const [r, c] of emptyCells()) if (winInfoAt(r, c, WHITE)) return [r, c];
let best = null, bestScore = -1;
for (const [r, c] of emptyCells()) {
let s = 0;
board[idx(r, c)] = BLACK; // would this point have been good for black?
for (const [dr, dc] of DIRS) {
const info = lineInfo(r, c, BLACK, dr, dc);
if (info.len >= 5) s = Math.max(s, 1000);
else if (info.len === 4 && info.ends >= 1) s = Math.max(s, 100 + info.ends);
else if (info.len === 3 && info.ends === 2) s = Math.max(s, 30);
else s = Math.max(s, info.len * 2 + info.ends);
}
board[idx(r, c)] = EMPTY;
if (s > bestScore) { bestScore = s; best = [r, c]; }
}
return best;
}
function winInfoAt(r, c, p) {
board[idx(r, c)] = p;
const w = winningAt(r, c, p);
board[idx(r, c)] = EMPTY;
return w;
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t; s.className = 'status ' + (cls || '');
}
function markWin(r, c, p) {
for (const [dr, dc] of DIRS) {
if (lineInfo(r, c, p, dr, dc).len >= 5) {
// walk to one end, then mark the run
let rr = r, cc = c;
while (inB(rr - dr, cc - dc) && board[idx(rr - dr, cc - dc)] === p) { rr -= dr; cc -= dc; }
while (inB(rr, cc) && board[idx(rr, cc)] === p) {
const el = document.querySelector('.pt[data-i="' + idx(rr, cc) + '"]');
if (el) el.classList.add('win');
rr += dr; cc += dc;
}
return;
}
}
}
function render() {
const b = document.getElementById('board'); b.innerHTML = '';
for (let r = 0; r < N; r++) for (let c = 0; c < N; c++) {
const d = document.createElement('div');
d.className = 'pt'; d.dataset.i = idx(r, c);
const v = board[idx(r, c)];
if (v !== EMPTY) {
const s = document.createElement('div');
s.className = 'stone ' + (v === BLACK ? 'b' : 'w');
d.appendChild(s);
} else {
d.addEventListener('click', () => play(r, c));
}
b.appendChild(d);
}
}
function play(r, c) {
if (over || board[idx(r, c)] !== EMPTY) return;
const fork = openFoursAfter(r, c, BLACK) >= 2;
board[idx(r, c)] = BLACK;
render();
if (winningAt(r, c, BLACK)) { markWin(r, c, BLACK); over = true; return setStatus('Five in a row — you win!', 'ok'); }
if (fork) { setStatus('Double threat! Two open fours — white can block only one.', 'ok');
console.log('Fork played at', r, c, '— white cannot stop both fours.'); }
else setStatus('White is thinking...', '');
setTimeout(() => {
if (over) return;
const wm = whiteMove();
if (!wm) { over = true; return setStatus('Board full — draw.', ''); }
board[idx(wm[0], wm[1])] = WHITE;
render();
if (winningAt(wm[0], wm[1], WHITE)) { markWin(wm[0], wm[1], WHITE); over = true; return setStatus('White made five. Try again — build a fork.', 'bad'); }
if (fork) setStatus('White blocked one four — the other is still open. Complete your five!', 'ok');
else setStatus('Your move. Click an empty point.', '');
}, 250);
}
function hint() {
if (over) return;
document.querySelectorAll('.pt.tip').forEach(e => e.classList.remove('tip'));
const f = findFork(BLACK);
if (f) {
const el = document.querySelector('.pt[data-i="' + idx(f[0], f[1]) + '"]');
if (el) el.classList.add('tip');
setStatus('Fork available: this point opens two fours at once.', 'ok');
} else {
setStatus('No fork yet — build two open threes that share a point first.', '');
}
}
function reset() {
board.fill(EMPTY); over = false; render();
setStatus('Your move. Click an empty point.', '');
}
document.getElementById('hint').addEventListener('click', hint);
document.getElementById('reset').addEventListener('click', reset);
render();
Watch what happens. White can always answer a single threat — a row of four with an open end. But the moment you place a stone that opens two fours simultaneously, white has only one move and two fires to put out. That fork is the whole secret: the first player, given enough room, can always manufacture one. Press Hint to see the engine point at a forking square.
Comments
Loading comments...