Introduction

Flow Free has been installed hundreds of millions of times. The premise is gentle: a grid holds a few pairs of colored dots, and you drag a pipe from each dot to its twin. Two rules only — pipes may never cross, and together they must cover every single cell.

It feels like doodling. You trace a path, it blocks another, you back up and try again. But underneath, each puzzle is a precise question: is there any way to route all the colors at once so that nothing crosses and no cell is left empty?

That puzzle has a real name in mathematics — Numberlink — and the innocent question "can it be solved?" turns out to be one of the hardest kinds of question we know how to ask.

Connect the Pairs

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();
Result
Console

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.

The Real Complexity

How hard is Flow Free, really? Not the dragging — the deciding.

  • Checking a finished board is trivial: confirm each color forms one connected path, no two paths share a cell, and every cell is used.
  • Brute force tries every way to grow the paths through the empty cells — the number of routings grows exponentially, hopeless on anything past a small grid.
  • It's NP-complete. The underlying puzzle is known in mathematics as Numberlink. Researchers proved that deciding whether a board has a valid solution is NP-complete — including the cover-every-cell variant that Flow Free uses (Adcock, Demaine, Demaine et al., and independently Kotsuma & Takenaga). The puzzle's wires and turns can be arranged to encode any SAT formula.
  • So deciding even is-this-board-solvable is equivalent to the whole NP-complete family — no known method beats trying combinations in the worst case.

That is the punchline: the moment a board can't be settled by quick local reasoning, you are staring at a genuine instance of the same problem behind P vs NP. The backtracking Flow Free makes you do by hand is intractability made playable.

Where It Matters

"Connect these pairs with non-crossing paths through a limited space" is exactly the shape of some very real engineering problems, and Flow Free is its friendly face:

  • Chip and circuit-board wiring: laying out wires that connect terminals without crossing, in the tight space of a VLSI chip or a PCB, is a disjoint-paths routing problem — the same core puzzle.
  • Network path planning: assigning non-overlapping routes to many connections across a shared fabric is the same kind of constraint.
  • Disjoint-paths problems: the abstract version, finding vertex-disjoint paths between given pairs in a graph, is a classic question in algorithms and combinatorial optimization.
  • Teaching complexity: because the rules fit on one screen, Flow Free is a vivid on-ramp to what NP-completeness means.

Learn why Flow Free is hard and you've met disjoint-path routing — the same difficulty that haunts SAT and graph problems like Hamiltonian paths.

Conclusion

Flow Free hides a beautiful secret: the same pipes that calm you down are the Numberlink puzzle, and deciding whether a board can be solved at all is NP-complete. Checking a finished routing stays instant; finding one is as hard as anything in computer science.

So the next time a level forces you to rip up half the board and start over, take comfort — you aren't bad at it. You've simply run into P vs NP wearing a coat of bright pipes, and there may be no clever shortcut around the search at all.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/flow-free/Content licensed under CC BY-NC 4.0.