Here is a small board with a few colored endpoints. Click an empty cell to extend the currently selected color's path from its last cell; build a path from each dot to its matching dot. The goal: no crossings and every cell filled.
<p class="hint">Each colored dot has a twin. Pick a color, then click empty cells to extend its path from its last cell. Connect every pair with <b>no crossings</b> and <b>fill every cell</b>. Checking a board is instant — finding a valid routing is the hard part.</p>
<div class="palette" id="palette"></div>
<div id="board" class="board"></div>
<div class="status" id="status">Pick a color, then click cells.</div>
<div class="btns">
<button id="check" type="button">Check board</button>
<button id="solve" type="button">Solve</button>
<button id="reset" type="button" class="ghost">Clear</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; }
.palette { display: flex; gap: .4rem; margin: 0 0 .5rem; flex-wrap: wrap; }
.swatch { width: 30px; height: 30px; border-radius: 50%; border: 3px solid transparent;
cursor: pointer; }
.swatch.sel { border-color: #1d3557; }
.board { display: grid; grid-template-columns: repeat(5, 46px); gap: 4px; margin: .4rem 0; }
.cell { width: 46px; height: 46px; display: flex; align-items: center; justify-content: center;
border-radius: 8px; user-select: none; background: #eef1f4; border: 1px solid #d7dde3;
cursor: pointer; transition: background .1s; position: relative; }
.cell:hover { background: #e3e8ed; }
.cell .dot { width: 30px; height: 30px; border-radius: 50%; }
.cell.path { border-radius: 4px; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Flow Free / Numberlink: connect each colored pair with non-crossing paths that FILL every cell. Checking a routing is a quick scan; deciding whether ANY valid routing exists is NP-complete (Adcock et al.; Kotsuma & Takenaga). "Solve" runs a backtracking search whose branching explodes as the grid grows.
const N = 5;
const COLORS = { A: '#e63946', B: '#2a9d8f', C: '#f4a261' };
const ORDER = ['A', 'B', 'C'];
// Two endpoints per color (row,col). A solving routing fills all 25 cells.
const ENDS = { A: [[0,0],[3,4]], B: [[2,2],[4,4]], C: [[3,2],[4,0]] };
const key = (r,c) => r + ',' + c;
const ENDSET = {};
for (const col in ENDS) for (const [r,c] of ENDS[col]) ENDSET[key(r,c)] = col;
let sel = 'A';
let grid = {}; // "r,c" -> color
let order = { A:[], B:[], C:[] }; // ordered path cells the player drew (excludes fixed ends)
function reset() {
grid = {}; order = { A:[], B:[], C:[] };
for (const col in ENDS) for (const [r,c] of ENDS[col]) grid[key(r,c)] = col;
render(); setStatus('Pick a color, then click cells.', '');
}
function inB(r,c){ return r>=0 && r<N && c>=0 && c<N; }
// Click a cell: extend the selected color from its current head into an empty neighbor, or undo the last drawn cell of that color if you click it.
function clickCell(r,c) {
const k = key(r,c);
if (ENDSET[k]) { sel = ENDSET[k]; render(); return; } // re-select by clicking a dot
if (grid[k] === sel) { // clicked own path -> trim back to it
const idx = order[sel].indexOf(k);
if (idx >= 0) { for (let i = order[sel].length-1; i >= idx; i--){ delete grid[order[sel][i]]; } order[sel].length = idx; render(); return; }
}
if (grid[k]) return; // occupied by another color
// head = last drawn cell, or the first fixed endpoint
const drawn = order[sel];
const head = drawn.length ? drawn[drawn.length-1].split(',').map(Number) : ENDS[sel][0];
const adj = Math.abs(head[0]-r) + Math.abs(head[1]-c) === 1;
if (!adj) { setStatus('Click a cell next to the path head.', 'bad'); return; }
grid[k] = sel; order[sel].push(k); render(); setStatus('Pick a color, then click cells.', '');
}
function render() {
const pal = document.getElementById('palette'); pal.innerHTML = '';
for (const col of ORDER) {
const s = document.createElement('div');
s.className = 'swatch' + (col===sel ? ' sel' : '');
s.style.background = COLORS[col];
s.addEventListener('click', () => { sel = col; render(); });
pal.appendChild(s);
}
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'); const k = key(r,c);
const col = grid[k];
d.className = 'cell' + (col && !ENDSET[k] ? ' path' : '');
if (col) {
if (ENDSET[k]) { const dot=document.createElement('div'); dot.className='dot'; dot.style.background=COLORS[col]; d.appendChild(dot); }
else d.style.background = COLORS[col];
}
d.addEventListener('click', () => clickCell(r,c));
b.appendChild(d);
}
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
// Full ordered path for a color = its first endpoint, the cells the player drew, then the second endpoint (when the last drawn cell sits next to it).
function fullPath(col) {
const p = [key(ENDS[col][0][0], ENDS[col][0][1]), ...order[col]];
const last = p[p.length-1].split(',').map(Number), end = ENDS[col][1];
if (Math.abs(last[0]-end[0]) + Math.abs(last[1]-end[1]) === 1) p.push(key(end[0], end[1]));
return p;
}
// A board is valid iff every cell is filled exactly once and each color is one path running endpoint-to-endpoint with adjacent steps. Pipes may hug themselves but never share a cell — true to the actual game.
function valid() {
if (Object.keys(grid).length !== N*N) return false;
const used = new Set();
for (const col of ORDER) {
const p = fullPath(col);
const a = ENDS[col][0], b = ENDS[col][1];
const okEnds = (p[0]===key(a[0],a[1]) && p[p.length-1]===key(b[0],b[1]));
if (!okEnds || p.length < 2) return false;
for (let i=0;i<p.length;i++) {
if (used.has(p[i])) return false; used.add(p[i]);
if (i>0) {
const u = p[i-1].split(',').map(Number), v = p[i].split(',').map(Number);
if (Math.abs(u[0]-v[0]) + Math.abs(u[1]-v[1]) !== 1) return false;
}
}
}
return used.size === N*N;
}
function check() {
if (valid()) setStatus('✓ All pairs connected, no crossings, every cell filled!', 'ok');
else setStatus('✗ Not solved yet — connect every pair and fill all cells.', 'bad');
}
// Backtracking search: grow each color's path from its first endpoint, cell by cell, never reusing a cell, until it reaches the second. A full assignment must fill the whole grid. It counts the partial states it explores so you can watch the branching.
function solve() {
const g = {}; for (const col in ENDS) for (const [r,c] of ENDS[col]) g[key(r,c)] = col;
const seq = { A:[], B:[], C:[] };
let nodes = 0;
const total = () => Object.keys(g).length;
function dfs(ci) {
nodes++;
if (ci === ORDER.length) return total() === N*N;
const col = ORDER[ci], src = ENDS[col][0], tgt = ENDS[col][1];
const cur = [key(src[0], src[1])];
function ext(r,c) {
if (Math.abs(r-tgt[0]) + Math.abs(c-tgt[1]) === 1) {
cur.push(key(tgt[0], tgt[1])); seq[col] = cur.slice();
if (dfs(ci+1)) return true; cur.pop();
}
for (const [dr,dc] of [[0,1],[0,-1],[1,0],[-1,0]]) {
const nr=r+dr, nc=c+dc;
if (!inB(nr,nc) || g[key(nr,nc)]) continue;
g[key(nr,nc)] = col; cur.push(key(nr,nc));
if (ext(nr,nc)) return true;
cur.pop(); delete g[key(nr,nc)];
}
return false;
}
return ext(src[0], src[1]);
}
const ok = dfs(0);
if (ok) {
grid = g; order = { A:[], B:[], C:[] };
// rebuild the player's order arrays = interior cells of each path
for (const col of ORDER) order[col] = seq[col].slice(1, -1);
render();
setStatus(fmt('Found a valid routing after exploring %nodes% partial states.', { nodes: nodes }), 'ok');
console.log('Solved Flow Free. Search explored', nodes, 'states.');
} else setStatus('No covering routing exists for these endpoints.', 'bad');
}
document.getElementById('check').addEventListener('click', check);
document.getElementById('solve').addEventListener('click', solve);
document.getElementById('reset').addEventListener('click', reset);
reset();
<p class="hint">Each colored dot has a twin. Pick a color, then click empty cells to extend its path from its last cell. Connect every pair with <b>no crossings</b> and <b>fill every cell</b>. Checking a board is instant — finding a valid routing is the hard part.</p>
<div class="palette" id="palette"></div>
<div id="board" class="board"></div>
<div class="status" id="status">Pick a color, then click cells.</div>
<div class="btns">
<button id="check" type="button">Check board</button>
<button id="solve" type="button">Solve</button>
<button id="reset" type="button" class="ghost">Clear</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; }
.palette { display: flex; gap: .4rem; margin: 0 0 .5rem; flex-wrap: wrap; }
.swatch { width: 30px; height: 30px; border-radius: 50%; border: 3px solid transparent;
cursor: pointer; }
.swatch.sel { border-color: #1d3557; }
.board { display: grid; grid-template-columns: repeat(5, 46px); gap: 4px; margin: .4rem 0; }
.cell { width: 46px; height: 46px; display: flex; align-items: center; justify-content: center;
border-radius: 8px; user-select: none; background: #eef1f4; border: 1px solid #d7dde3;
cursor: pointer; transition: background .1s; position: relative; }
.cell:hover { background: #e3e8ed; }
.cell .dot { width: 30px; height: 30px; border-radius: 50%; }
.cell.path { border-radius: 4px; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Flow Free / Numberlink: connect each colored pair with non-crossing paths that FILL every cell. Checking a routing is a quick scan; deciding whether ANY valid routing exists is NP-complete (Adcock et al.; Kotsuma & Takenaga). "Solve" runs a backtracking search whose branching explodes as the grid grows.
const N = 5;
const COLORS = { A: '#e63946', B: '#2a9d8f', C: '#f4a261' };
const ORDER = ['A', 'B', 'C'];
// Two endpoints per color (row,col). A solving routing fills all 25 cells.
const ENDS = { A: [[0,0],[3,4]], B: [[2,2],[4,4]], C: [[3,2],[4,0]] };
const key = (r,c) => r + ',' + c;
const ENDSET = {};
for (const col in ENDS) for (const [r,c] of ENDS[col]) ENDSET[key(r,c)] = col;
let sel = 'A';
let grid = {}; // "r,c" -> color
let order = { A:[], B:[], C:[] }; // ordered path cells the player drew (excludes fixed ends)
function reset() {
grid = {}; order = { A:[], B:[], C:[] };
for (const col in ENDS) for (const [r,c] of ENDS[col]) grid[key(r,c)] = col;
render(); setStatus('Pick a color, then click cells.', '');
}
function inB(r,c){ return r>=0 && r<N && c>=0 && c<N; }
// Click a cell: extend the selected color from its current head into an empty neighbor, or undo the last drawn cell of that color if you click it.
function clickCell(r,c) {
const k = key(r,c);
if (ENDSET[k]) { sel = ENDSET[k]; render(); return; } // re-select by clicking a dot
if (grid[k] === sel) { // clicked own path -> trim back to it
const idx = order[sel].indexOf(k);
if (idx >= 0) { for (let i = order[sel].length-1; i >= idx; i--){ delete grid[order[sel][i]]; } order[sel].length = idx; render(); return; }
}
if (grid[k]) return; // occupied by another color
// head = last drawn cell, or the first fixed endpoint
const drawn = order[sel];
const head = drawn.length ? drawn[drawn.length-1].split(',').map(Number) : ENDS[sel][0];
const adj = Math.abs(head[0]-r) + Math.abs(head[1]-c) === 1;
if (!adj) { setStatus('Click a cell next to the path head.', 'bad'); return; }
grid[k] = sel; order[sel].push(k); render(); setStatus('Pick a color, then click cells.', '');
}
function render() {
const pal = document.getElementById('palette'); pal.innerHTML = '';
for (const col of ORDER) {
const s = document.createElement('div');
s.className = 'swatch' + (col===sel ? ' sel' : '');
s.style.background = COLORS[col];
s.addEventListener('click', () => { sel = col; render(); });
pal.appendChild(s);
}
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'); const k = key(r,c);
const col = grid[k];
d.className = 'cell' + (col && !ENDSET[k] ? ' path' : '');
if (col) {
if (ENDSET[k]) { const dot=document.createElement('div'); dot.className='dot'; dot.style.background=COLORS[col]; d.appendChild(dot); }
else d.style.background = COLORS[col];
}
d.addEventListener('click', () => clickCell(r,c));
b.appendChild(d);
}
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
// Full ordered path for a color = its first endpoint, the cells the player drew, then the second endpoint (when the last drawn cell sits next to it).
function fullPath(col) {
const p = [key(ENDS[col][0][0], ENDS[col][0][1]), ...order[col]];
const last = p[p.length-1].split(',').map(Number), end = ENDS[col][1];
if (Math.abs(last[0]-end[0]) + Math.abs(last[1]-end[1]) === 1) p.push(key(end[0], end[1]));
return p;
}
// A board is valid iff every cell is filled exactly once and each color is one path running endpoint-to-endpoint with adjacent steps. Pipes may hug themselves but never share a cell — true to the actual game.
function valid() {
if (Object.keys(grid).length !== N*N) return false;
const used = new Set();
for (const col of ORDER) {
const p = fullPath(col);
const a = ENDS[col][0], b = ENDS[col][1];
const okEnds = (p[0]===key(a[0],a[1]) && p[p.length-1]===key(b[0],b[1]));
if (!okEnds || p.length < 2) return false;
for (let i=0;i<p.length;i++) {
if (used.has(p[i])) return false; used.add(p[i]);
if (i>0) {
const u = p[i-1].split(',').map(Number), v = p[i].split(',').map(Number);
if (Math.abs(u[0]-v[0]) + Math.abs(u[1]-v[1]) !== 1) return false;
}
}
}
return used.size === N*N;
}
function check() {
if (valid()) setStatus('✓ All pairs connected, no crossings, every cell filled!', 'ok');
else setStatus('✗ Not solved yet — connect every pair and fill all cells.', 'bad');
}
// Backtracking search: grow each color's path from its first endpoint, cell by cell, never reusing a cell, until it reaches the second. A full assignment must fill the whole grid. It counts the partial states it explores so you can watch the branching.
function solve() {
const g = {}; for (const col in ENDS) for (const [r,c] of ENDS[col]) g[key(r,c)] = col;
const seq = { A:[], B:[], C:[] };
let nodes = 0;
const total = () => Object.keys(g).length;
function dfs(ci) {
nodes++;
if (ci === ORDER.length) return total() === N*N;
const col = ORDER[ci], src = ENDS[col][0], tgt = ENDS[col][1];
const cur = [key(src[0], src[1])];
function ext(r,c) {
if (Math.abs(r-tgt[0]) + Math.abs(c-tgt[1]) === 1) {
cur.push(key(tgt[0], tgt[1])); seq[col] = cur.slice();
if (dfs(ci+1)) return true; cur.pop();
}
for (const [dr,dc] of [[0,1],[0,-1],[1,0],[-1,0]]) {
const nr=r+dr, nc=c+dc;
if (!inB(nr,nc) || g[key(nr,nc)]) continue;
g[key(nr,nc)] = col; cur.push(key(nr,nc));
if (ext(nr,nc)) return true;
cur.pop(); delete g[key(nr,nc)];
}
return false;
}
return ext(src[0], src[1]);
}
const ok = dfs(0);
if (ok) {
grid = g; order = { A:[], B:[], C:[] };
// rebuild the player's order arrays = interior cells of each path
for (const col of ORDER) order[col] = seq[col].slice(1, -1);
render();
setStatus(fmt('Found a valid routing after exploring %nodes% partial states.', { nodes: nodes }), 'ok');
console.log('Solved Flow Free. Search explored', nodes, 'states.');
} else setStatus('No covering routing exists for these endpoints.', 'bad');
}
document.getElementById('check').addEventListener('click', check);
document.getElementById('solve').addEventListener('click', solve);
document.getElementById('reset').addEventListener('click', reset);
reset();
Notice the asymmetry. Checking a finished board is effortless: trace each color and confirm the paths connect, never overlap, and cover everything. Finding such a routing is the hard part — press Solve and the computer searches the tree of possible moves. Each empty cell multiplies the branches, and the count explodes long before the grid looks big.
Comments
Loading comments...