<p class="hint">You are <b class="blue">Blue</b> — connect the <b>left and right</b> edges.
The bot is <b class="red">Red</b> — it connects <b>top and bottom</b>. Click an empty
hexagon to play; the bot replies. The moment a color links its two sides, the winning
chain lights up. The board can never end in a draw.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Your move, Blue.</div>
<div class="btns">
<button id="reset" type="button">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; }
.blue { color: #2563eb; }
.red { color: #dc2626; }
.board { position: relative; width: 320px; height: 250px; margin: .4rem auto; }
.hexwrap { position: absolute; width: 40px; height: 46px; }
.hex { width: 100%; height: 100%; cursor: pointer;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
background: #e8eef3; border: none; transition: background .12s; display: block; }
.hex.empty:hover { background: #d4dde6; }
.hex.blue { background: #2563eb; cursor: default; }
.hex.red { background: #dc2626; cursor: default; }
.hex.win { box-shadow: 0 0 0 3px #fbbf24; filter: brightness(1.15); }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; text-align: center; }
.status.blue { color: #2563eb; }
.status.red { color: #dc2626; }
.btns { display: flex; gap: .5rem; justify-content: center; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
// Hex: Blue links LEFT<->RIGHT, Red links TOP<->BOTTOM. On a full board exactly one side connects — a draw is impossible. Deciding the winner of a general position is PSPACE-complete (Reisch, 1981); the first-player win is proven nonconstructively by strategy stealing. Here we just play and detect the connection with a flood fill.
const N = 5;
// Hex neighbors on a rhombic board: the 6 adjacent cells.
const DIRS = [[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0]];
let board, current, over;
function reset(){
board = Array.from({length:N}, () => Array(N).fill(0)); // 0 empty, 1 blue, 2 red
current = 1; over = false;
render(); setStatus('Your move, Blue.','blue');
}
function inB(r,c){ return r>=0 && r<N && c>=0 && c<N; }
function nbrs(r,c){ return DIRS.map(([dr,dc])=>[r+dr,c+dc]).filter(([a,b])=>inB(a,b)); }
// Flood fill: does `player` connect its two sides? Returns the winning cell set or null.
function winningSet(player){
const seen = new Set(); const stack = [];
// seed from the player's "first" side
for(let i=0;i<N;i++){
const [r,c] = player===1 ? [i,0] : [0,i]; // blue starts at left col, red at top row
if(board[r][c]===player){ const k=r+','+c; if(!seen.has(k)){ seen.add(k); stack.push([r,c]); } }
}
while(stack.length){
const [r,c] = stack.pop();
for(const [a,b] of nbrs(r,c)){
if(board[a][b]===player){ const k=a+','+b; if(!seen.has(k)){ seen.add(k); stack.push([a,b]); } }
}
}
// did we reach the far side?
const reached = [...seen].some(k=>{ const [r,c]=k.split(',').map(Number);
return player===1 ? c===N-1 : r===N-1; });
return reached ? seen : null;
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+(cls||''); }
function render(winSet){
const b = document.getElementById('board'); b.innerHTML='';
const dx=34, dy=38, sh=20; // horizontal step, vertical step, per-row shift
for(let r=0;r<N;r++)for(let c=0;c<N;c++){
const w=document.createElement('div'); w.className='hexwrap';
w.style.left=(c*dx + r*sh)+'px'; w.style.top=(r*dy)+'px';
const h=document.createElement('button'); h.type='button';
const v=board[r][c]; const colorCls = v===1?'blue':v===2?'red':'empty';
h.className='hex '+colorCls + (winSet && winSet.has(r+','+c)?' win':'');
if(v===0 && !over) h.addEventListener('click',()=>play(r,c));
w.appendChild(h); b.appendChild(w);
}
}
function endIf(){
const bw=winningSet(1); if(bw){ over=true; render(bw); setStatus('Blue connects left to right — Blue wins!','blue'); return true; }
const rw=winningSet(2); if(rw){ over=true; render(rw); setStatus('Red connects top to bottom — Red wins.','red'); return true; }
return false;
}
function play(r,c){
if(over || board[r][c]!==0) return;
board[r][c]=1; render(); if(endIf()) return; // Blue moves
botMove(); // Red replies
}
// Simple bot: take any immediately-winning cell, else the empty cell with the most Red neighbors (greedy toward connection), else a random empty cell.
function botMove(){
const empties=[]; for(let r=0;r<N;r++)for(let c=0;c<N;c++) if(board[r][c]===0) empties.push([r,c]);
if(!empties.length){ over=true; setStatus('Board full.',''); return; }
let pick=null;
for(const [r,c] of empties){ board[r][c]=2; if(winningSet(2)){ pick=[r,c]; board[r][c]=0; break; } board[r][c]=0; }
if(!pick){
let best=-1; for(const [r,c] of empties){ const score=nbrs(r,c).filter(([a,b])=>board[a][b]===2).length;
if(score>best){ best=score; pick=[r,c]; } }
}
const [r,c]=pick; board[r][c]=2; render(); if(endIf()) return;
setStatus('Your move, Blue.','blue');
}
document.getElementById('reset').addEventListener('click',reset);
reset();
<p class="hint">You are <b class="blue">Blue</b> — connect the <b>left and right</b> edges.
The bot is <b class="red">Red</b> — it connects <b>top and bottom</b>. Click an empty
hexagon to play; the bot replies. The moment a color links its two sides, the winning
chain lights up. The board can never end in a draw.</p>
<div id="board" class="board"></div>
<div class="status" id="status">Your move, Blue.</div>
<div class="btns">
<button id="reset" type="button">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; }
.blue { color: #2563eb; }
.red { color: #dc2626; }
.board { position: relative; width: 320px; height: 250px; margin: .4rem auto; }
.hexwrap { position: absolute; width: 40px; height: 46px; }
.hex { width: 100%; height: 100%; cursor: pointer;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
background: #e8eef3; border: none; transition: background .12s; display: block; }
.hex.empty:hover { background: #d4dde6; }
.hex.blue { background: #2563eb; cursor: default; }
.hex.red { background: #dc2626; cursor: default; }
.hex.win { box-shadow: 0 0 0 3px #fbbf24; filter: brightness(1.15); }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; text-align: center; }
.status.blue { color: #2563eb; }
.status.red { color: #dc2626; }
.btns { display: flex; gap: .5rem; justify-content: center; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
// Hex: Blue links LEFT<->RIGHT, Red links TOP<->BOTTOM. On a full board exactly one side connects — a draw is impossible. Deciding the winner of a general position is PSPACE-complete (Reisch, 1981); the first-player win is proven nonconstructively by strategy stealing. Here we just play and detect the connection with a flood fill.
const N = 5;
// Hex neighbors on a rhombic board: the 6 adjacent cells.
const DIRS = [[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0]];
let board, current, over;
function reset(){
board = Array.from({length:N}, () => Array(N).fill(0)); // 0 empty, 1 blue, 2 red
current = 1; over = false;
render(); setStatus('Your move, Blue.','blue');
}
function inB(r,c){ return r>=0 && r<N && c>=0 && c<N; }
function nbrs(r,c){ return DIRS.map(([dr,dc])=>[r+dr,c+dc]).filter(([a,b])=>inB(a,b)); }
// Flood fill: does `player` connect its two sides? Returns the winning cell set or null.
function winningSet(player){
const seen = new Set(); const stack = [];
// seed from the player's "first" side
for(let i=0;i<N;i++){
const [r,c] = player===1 ? [i,0] : [0,i]; // blue starts at left col, red at top row
if(board[r][c]===player){ const k=r+','+c; if(!seen.has(k)){ seen.add(k); stack.push([r,c]); } }
}
while(stack.length){
const [r,c] = stack.pop();
for(const [a,b] of nbrs(r,c)){
if(board[a][b]===player){ const k=a+','+b; if(!seen.has(k)){ seen.add(k); stack.push([a,b]); } }
}
}
// did we reach the far side?
const reached = [...seen].some(k=>{ const [r,c]=k.split(',').map(Number);
return player===1 ? c===N-1 : r===N-1; });
return reached ? seen : null;
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+(cls||''); }
function render(winSet){
const b = document.getElementById('board'); b.innerHTML='';
const dx=34, dy=38, sh=20; // horizontal step, vertical step, per-row shift
for(let r=0;r<N;r++)for(let c=0;c<N;c++){
const w=document.createElement('div'); w.className='hexwrap';
w.style.left=(c*dx + r*sh)+'px'; w.style.top=(r*dy)+'px';
const h=document.createElement('button'); h.type='button';
const v=board[r][c]; const colorCls = v===1?'blue':v===2?'red':'empty';
h.className='hex '+colorCls + (winSet && winSet.has(r+','+c)?' win':'');
if(v===0 && !over) h.addEventListener('click',()=>play(r,c));
w.appendChild(h); b.appendChild(w);
}
}
function endIf(){
const bw=winningSet(1); if(bw){ over=true; render(bw); setStatus('Blue connects left to right — Blue wins!','blue'); return true; }
const rw=winningSet(2); if(rw){ over=true; render(rw); setStatus('Red connects top to bottom — Red wins.','red'); return true; }
return false;
}
function play(r,c){
if(over || board[r][c]!==0) return;
board[r][c]=1; render(); if(endIf()) return; // Blue moves
botMove(); // Red replies
}
// Simple bot: take any immediately-winning cell, else the empty cell with the most Red neighbors (greedy toward connection), else a random empty cell.
function botMove(){
const empties=[]; for(let r=0;r<N;r++)for(let c=0;c<N;c++) if(board[r][c]===0) empties.push([r,c]);
if(!empties.length){ over=true; setStatus('Board full.',''); return; }
let pick=null;
for(const [r,c] of empties){ board[r][c]=2; if(winningSet(2)){ pick=[r,c]; board[r][c]=0; break; } board[r][c]=0; }
if(!pick){
let best=-1; for(const [r,c] of empties){ const score=nbrs(r,c).filter(([a,b])=>board[a][b]===2).length;
if(score>best){ best=score; pick=[r,c]; } }
}
const [r,c]=pick; board[r][c]=2; render(); if(endIf()) return;
setStatus('Your move, Blue.','blue');
}
document.getElementById('reset').addEventListener('click',reset);
reset();
Try to lose on purpose by leaving gaps — you will find the board simply refuses to stay unfinished without a winner. When the last cell is colored, someone has always connected. That no-draw guarantee is what makes the next fact possible: a clean argument that the first player wins, even though the board above is small enough to fill in by brute force but a real 11×11 board is not.
Comments
Loading comments...