<p class="hint">You are <b>yellow</b>; the engine is <b>red</b> and plays <b>perfectly</b>. On this 5×4 board it searches the entire game tree before every move, so it never loses. Try to beat it — the best you can do is a draw.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Your move. Click a column.</div>
<div class="btns">
<button id="reset" type="button">New game</button>
<label class="who"><input type="checkbox" id="first"> Let the engine move first</label>
</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: inline-grid; grid-template-columns: repeat(5, 52px);
gap: 6px; padding: 8px; background: #1d3557; border-radius: 12px; }
.cell { width: 52px; height: 52px; border-radius: 50%; background: #f4f6f9;
cursor: pointer; transition: transform .08s; }
.cell:hover { transform: scale(1.05); }
.cell.y { background: radial-gradient(circle at 35% 30%, #ffe066, #f1c40f); cursor: default; }
.cell.r { background: radial-gradient(circle at 35% 30%, #ff7a85, #e63946); cursor: default; }
.cell.win { box-shadow: 0 0 0 3px #0a7d33 inset; }
.status { font-size: 1rem; font-weight: 600; margin: .7rem 0 .5rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: 1rem; align-items: center; 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; }
.who { font-size: .85rem; color: #444; display: flex; gap: .35rem; align-items: center; }
// Connect Four on a small 5x4 board (connect FOUR). The engine plays PERFECTLY:
// before every move it runs minimax over the WHOLE remaining game tree and picks
// a move that, against any reply, never loses. This mirrors how the full 7x6 game was solved (Allis & Allen, 1988) — first player wins from the center.
const W = 5, H = 4, NEED = 4;
const HUMAN = 1, ENGINE = 2; // 1 = yellow, 2 = red
let grid, turn, over; // grid[col] = array of discs bottom-up
function newGame(engineFirst){
grid = Array.from({length: W}, () => []);
over = false;
turn = engineFirst ? ENGINE : HUMAN;
render();
if (turn === ENGINE) setTimeout(engineMove, 250);
else setStatus('Your move. Click a column.','');
}
function cellAt(c, r){ return grid[c][r] || 0; } // r from bottom
function legal(g){ const m=[]; for(let c=0;c<W;c++) if(g[c].length<H) m.push(c); return m; }
function clone(g){ return g.map(col => col.slice()); }
function drop(g, c, who){ g[c].push(who); }
// Does `who` have NEED in a row anywhere? checks 4 directions.
function wins(g, who){
const at=(c,r)=> (r>=0 && r<g[c].length) ? g[c][r] : 0;
const dirs=[[1,0],[0,1],[1,1],[1,-1]];
for(let c=0;c<W;c++) for(let r=0;r<H;r++){
if(at(c,r)!==who) continue;
for(const [dc,dr] of dirs){
let k=1; while(k<NEED){ const nc=c+dc*k, nr=r+dr*k;
if(nc<0||nc>=W||nr<0||nr>=H||at(nc,nr)!==who) break; k++; }
if(k===NEED) return true;
}
}
return false;
}
// Minimax: returns +1 if ENGINE can force a win, 0 draw, -1 loss, from the
// position where `toMove` is to move. Full-depth, exact (board is tiny).
function minimax(g, toMove){
if(wins(g, ENGINE)) return 1;
if(wins(g, HUMAN)) return -1;
const moves = legal(g);
if(moves.length === 0) return 0; // full board, no winner = draw
if(toMove === ENGINE){
let best = -2;
for(const c of moves){ const ng=clone(g); drop(ng,c,ENGINE);
best = Math.max(best, minimax(ng, HUMAN)); if(best===1) break; }
return best;
} else {
let best = 2;
for(const c of moves){ const ng=clone(g); drop(ng,c,HUMAN);
best = Math.min(best, minimax(ng, ENGINE)); if(best===-1) break; }
return best;
}
}
// Pick the engine's best move: prefer a forced win, else a draw, else delay loss.
function bestMove(g){
const moves = legal(g);
let bestVal=-2, choice=moves[0];
for(const c of moves){ const ng=clone(g); drop(ng,c,ENGINE);
const v = wins(ng,ENGINE) ? 1 : minimax(ng, HUMAN);
if(v>bestVal){ bestVal=v; choice=c; } }
return choice;
}
function engineMove(){
if(over) return;
const c = bestMove(grid);
drop(grid, c, ENGINE);
if(finishIf()) return;
turn = HUMAN; render(); setStatus('Your move. Click a column.','');
}
function humanMove(c){
if(over || turn!==HUMAN || grid[c].length>=H) return;
drop(grid, c, HUMAN);
if(finishIf()) return;
turn = ENGINE; render(); setStatus('Engine is thinking…','');
setTimeout(engineMove, 250);
}
function finishIf(){
render();
if(wins(grid, ENGINE)){ over=true; setStatus('Red (engine) wins. It played perfectly.','bad'); return true; }
if(wins(grid, HUMAN)){ over=true; setStatus('You win! (That should be impossible here.)','ok'); return true; }
if(legal(grid).length===0){ over=true; setStatus('Draw — the best you can force against perfect play.','ok'); return true; }
return false;
}
function render(){
const b=document.getElementById('board'); b.innerHTML='';
// rows top (H-1) down to 0 so gravity looks right
for(let r=H-1;r>=0;r--) for(let c=0;c<W;c++){
const d=document.createElement('div'); const v=cellAt(c,r);
d.className='cell'+(v===HUMAN?' y':v===ENGINE?' r':'');
if(v===0) d.addEventListener('click',()=>humanMove(c));
b.appendChild(d);
}
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
document.getElementById('reset').addEventListener('click',()=>newGame(document.getElementById('first').checked));
document.getElementById('first').addEventListener('change',e=>newGame(e.target.checked));
newGame(false);
<p class="hint">You are <b>yellow</b>; the engine is <b>red</b> and plays <b>perfectly</b>. On this 5×4 board it searches the entire game tree before every move, so it never loses. Try to beat it — the best you can do is a draw.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Your move. Click a column.</div>
<div class="btns">
<button id="reset" type="button">New game</button>
<label class="who"><input type="checkbox" id="first"> Let the engine move first</label>
</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: inline-grid; grid-template-columns: repeat(5, 52px);
gap: 6px; padding: 8px; background: #1d3557; border-radius: 12px; }
.cell { width: 52px; height: 52px; border-radius: 50%; background: #f4f6f9;
cursor: pointer; transition: transform .08s; }
.cell:hover { transform: scale(1.05); }
.cell.y { background: radial-gradient(circle at 35% 30%, #ffe066, #f1c40f); cursor: default; }
.cell.r { background: radial-gradient(circle at 35% 30%, #ff7a85, #e63946); cursor: default; }
.cell.win { box-shadow: 0 0 0 3px #0a7d33 inset; }
.status { font-size: 1rem; font-weight: 600; margin: .7rem 0 .5rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: 1rem; align-items: center; 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; }
.who { font-size: .85rem; color: #444; display: flex; gap: .35rem; align-items: center; }
// Connect Four on a small 5x4 board (connect FOUR). The engine plays PERFECTLY:
// before every move it runs minimax over the WHOLE remaining game tree and picks
// a move that, against any reply, never loses. This mirrors how the full 7x6 game was solved (Allis & Allen, 1988) — first player wins from the center.
const W = 5, H = 4, NEED = 4;
const HUMAN = 1, ENGINE = 2; // 1 = yellow, 2 = red
let grid, turn, over; // grid[col] = array of discs bottom-up
function newGame(engineFirst){
grid = Array.from({length: W}, () => []);
over = false;
turn = engineFirst ? ENGINE : HUMAN;
render();
if (turn === ENGINE) setTimeout(engineMove, 250);
else setStatus('Your move. Click a column.','');
}
function cellAt(c, r){ return grid[c][r] || 0; } // r from bottom
function legal(g){ const m=[]; for(let c=0;c<W;c++) if(g[c].length<H) m.push(c); return m; }
function clone(g){ return g.map(col => col.slice()); }
function drop(g, c, who){ g[c].push(who); }
// Does `who` have NEED in a row anywhere? checks 4 directions.
function wins(g, who){
const at=(c,r)=> (r>=0 && r<g[c].length) ? g[c][r] : 0;
const dirs=[[1,0],[0,1],[1,1],[1,-1]];
for(let c=0;c<W;c++) for(let r=0;r<H;r++){
if(at(c,r)!==who) continue;
for(const [dc,dr] of dirs){
let k=1; while(k<NEED){ const nc=c+dc*k, nr=r+dr*k;
if(nc<0||nc>=W||nr<0||nr>=H||at(nc,nr)!==who) break; k++; }
if(k===NEED) return true;
}
}
return false;
}
// Minimax: returns +1 if ENGINE can force a win, 0 draw, -1 loss, from the
// position where `toMove` is to move. Full-depth, exact (board is tiny).
function minimax(g, toMove){
if(wins(g, ENGINE)) return 1;
if(wins(g, HUMAN)) return -1;
const moves = legal(g);
if(moves.length === 0) return 0; // full board, no winner = draw
if(toMove === ENGINE){
let best = -2;
for(const c of moves){ const ng=clone(g); drop(ng,c,ENGINE);
best = Math.max(best, minimax(ng, HUMAN)); if(best===1) break; }
return best;
} else {
let best = 2;
for(const c of moves){ const ng=clone(g); drop(ng,c,HUMAN);
best = Math.min(best, minimax(ng, ENGINE)); if(best===-1) break; }
return best;
}
}
// Pick the engine's best move: prefer a forced win, else a draw, else delay loss.
function bestMove(g){
const moves = legal(g);
let bestVal=-2, choice=moves[0];
for(const c of moves){ const ng=clone(g); drop(ng,c,ENGINE);
const v = wins(ng,ENGINE) ? 1 : minimax(ng, HUMAN);
if(v>bestVal){ bestVal=v; choice=c; } }
return choice;
}
function engineMove(){
if(over) return;
const c = bestMove(grid);
drop(grid, c, ENGINE);
if(finishIf()) return;
turn = HUMAN; render(); setStatus('Your move. Click a column.','');
}
function humanMove(c){
if(over || turn!==HUMAN || grid[c].length>=H) return;
drop(grid, c, HUMAN);
if(finishIf()) return;
turn = ENGINE; render(); setStatus('Engine is thinking…','');
setTimeout(engineMove, 250);
}
function finishIf(){
render();
if(wins(grid, ENGINE)){ over=true; setStatus('Red (engine) wins. It played perfectly.','bad'); return true; }
if(wins(grid, HUMAN)){ over=true; setStatus('You win! (That should be impossible here.)','ok'); return true; }
if(legal(grid).length===0){ over=true; setStatus('Draw — the best you can force against perfect play.','ok'); return true; }
return false;
}
function render(){
const b=document.getElementById('board'); b.innerHTML='';
// rows top (H-1) down to 0 so gravity looks right
for(let r=H-1;r>=0;r--) for(let c=0;c<W;c++){
const d=document.createElement('div'); const v=cellAt(c,r);
d.className='cell'+(v===HUMAN?' y':v===ENGINE?' r':'');
if(v===0) d.addEventListener('click',()=>humanMove(c));
b.appendChild(d);
}
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
document.getElementById('reset').addEventListener('click',()=>newGame(document.getElementById('first').checked));
document.getElementById('first').addEventListener('change',e=>newGame(e.target.checked));
newGame(false);
Try to beat it. You can't — at best you draw. The engine isn't guessing or using tricks: for every move it runs minimax, exploring the entire game tree to the end and picking a move that, against any reply you could make, never lets it lose. This is exactly how the full 7×6 game was conquered, just on a board small enough to solve live in your browser.
Comments
Loading comments...