← Back to projects

LinkedIn Zip Solver

Active· javascript, puzzle, solver, backtracking

Zip is LinkedIn’s daily path puzzle: an N×N grid with a handful of numbered cells and some walls between cells. Draw one continuous line that passes through the numbers in order — 1, then 2, then 3 — and fills every cell exactly once, never crossing a wall. Unlike the other LinkedIn games there’s no tapping: you drag the path out cell by cell. This script reads the board, solves it, and performs that drag for you.

Script

Open the puzzle, open the console (F12), and paste this. It’s the full script from the bottom of this page, golfed as far as it will go:

(async () => {
const C = [...document.querySelectorAll('[data-cell-idx]')].sort((a, b) => a.dataset.cellIdx - b.dataset.cellIdx);
const n = Math.round(Math.sqrt(C.length)), N = n * n, K = (a, b) => a < b ? a + '-' + b : b + '-' + a;
const num = Array(N).fill(0), W = new Set();
C.forEach((el, i) => { const m = (el.getAttribute('aria-label') || '').match(/Number (\d+)/); if (m) num[i] = +m[1];
  const r = i / n | 0, c = i % n; for (const k of el.children) { const s = getComputedStyle(k, '::after'); if (s.content === 'none') continue; const t = p => parseFloat(s['border' + p + 'Width']) >= 6;
    if (t('Right') && c < n - 1) W.add(K(i, i + 1)); if (t('Left') && c > 0) W.add(K(i, i - 1)); if (t('Bottom') && r < n - 1) W.add(K(i, i + n)); if (t('Top') && r > 0) W.add(K(i, i - n)); } });
const mx = Math.max(0, ...num), nb = i => { const r = i / n | 0, c = i % n, o = []; if (r > 0) o.push(i - n); if (r < n - 1) o.push(i + n); if (c > 0) o.push(i - 1); if (c < n - 1) o.push(i + 1); return o.filter(j => !W.has(K(i, j))); };
const vis = Array(N).fill(false), P = [];
const conn = cur => { let tot = 0; for (let i = 0; i < N; i++) if (!vis[i]) tot++; if (!tot) return true; const sn = new Uint8Array(N), st = []; for (const x of nb(cur)) if (!vis[x] && !sn[x]) sn[x] = 1, st.push(x); let c = 0; while (st.length) { const x = st.pop(); c++; for (const y of nb(x)) if (!vis[y] && !sn[y]) sn[y] = 1, st.push(y); } return c === tot; };
const dfs = (cell, f, need) => { if (f === N) return need === mx + 1; if (!conn(cell)) return false; for (const x of nb(cell)) { if (vis[x]) continue; const d = num[x]; if (d && d !== need) continue; vis[x] = 1; P.push(x); if (dfs(x, f + 1, d ? need + 1 : need)) return true; vis[x] = 0; P.pop(); } return false; };
const s = num.indexOf(1); if (s < 0 || (vis[s] = 1, P.push(s), !dfs(s, 1, 2))) return console.error('🔗 Zip: no solution found for this board.');
const F = (el, X, t, b) => { const r = el.getBoundingClientRect(); el.dispatchEvent(new X(t, { bubbles: 1, cancelable: 1, composed: 1, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: 1, button: 0, buttons: b, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2 })); };
const en = (el, b) => { for (const [X, t] of [[PointerEvent, 'pointerover'], [PointerEvent, 'pointerenter'], [MouseEvent, 'mouseover'], [MouseEvent, 'mouseenter'], [PointerEvent, 'pointermove'], [MouseEvent, 'mousemove']]) F(el, X, t, b); };
const lv = el => { for (const [X, t] of [[PointerEvent, 'pointerout'], [PointerEvent, 'pointerleave'], [MouseEvent, 'mouseout'], [MouseEvent, 'mouseleave']]) F(el, X, t, 1); };
const fr = () => new Promise(r => requestAnimationFrame(() => setTimeout(r, 28)));
en(C[P[0]], 0); F(C[P[0]], PointerEvent, 'pointerdown', 1); F(C[P[0]], MouseEvent, 'mousedown', 1); F(C[P[0]], PointerEvent, 'pointermove', 1); F(C[P[0]], MouseEvent, 'mousemove', 1); await fr();
for (let k = 1; k < P.length; k++) { lv(C[P[k - 1]]); en(C[P[k]], 1); await fr(); }
const L = C[P[P.length - 1]]; F(L, PointerEvent, 'pointerup', 0); F(L, MouseEvent, 'mouseup', 0); F(L, MouseEvent, 'click', 0);
console.log(`🔗 Zip ${n}×${n} drawn ✅ — ${P.length} cells, ${W.size} walls`);
})();

The one insight that makes it easy

Like the other LinkedIn games, the whole board is in the DOM. The 36 (or 49, or however many) [data-cell-idx] cells each carry their number in an aria-label="Number N", so the size is just √(cell count) — never assume 6×6.

The genuinely new part is the walls. They’re drawn as the ::after pseudo-element of a child <div>, with a thick border on the one side the wall sits on — border-right for a wall to the right, and so on. The cell’s own borders are a uniform 1px (those are the grid lines), so reading each child’s ::after border widths and keeping anything ≥6px picks out the walls and ignores the grid. Each wall is drawn on both cells it divides, so collecting them into one set of “blocked edges” deduplicates them for free.

The algorithm

With the numbers and blocked edges in hand it’s a constrained Hamiltonian path. A depth-first search starts at the cell numbered 1 and walks to open (unwalled) neighbours, refusing to step onto a numbered cell unless it’s the next number due. It succeeds when every cell is filled and the last number has been reached.

The one search that makes it fast is a connectivity check: after each step, a flood fill confirms every still-unvisited cell is reachable from where the path now stands. The moment a move would strand a cell, the branch is abandoned — which turns a board that would otherwise take millions of steps into one that settles in a fraction of a second. (A tempting simpler prune — “every unvisited cell must keep a neighbour” — is wrong: it kills the path’s own dead-end finish.)

Driving the board

Zip is the first of these games that’s drawn rather than tapped, and it took some reverse-engineering. The app tracks the pointer through a normal drag — pointerdown on the start cell, pointerover/pointerenter as the cursor crosses into each new cell, pointerup at the end — so the script fires that same sequence synthetically (with mouse-event mirrors, since some handlers listen for those too), leaving each cell as it enters the next.

The catch is timing. The app samples the pointer position once per animation frame, so if the events fire too fast it only sees every second or third cell, “connects the dots” between those samples, and rejects the result with “Follow the numbers in order.” Advancing exactly one cell per requestAnimationFrame fixes it — the path then traces every cell in turn and the board completes.

How it was built

The solver’s core is a pure function unit-tested with node --test: a small open board, a number-order test (only the in-order Hamiltonian path is accepted), a discriminating wall test (solve the open board, wall an edge the solution used, and confirm the re-solve reroutes and never crosses it), an infeasible board walled off into null, and both live boards — the 6×6 with ten waypoints and the 7×7 with eighteen walls. It was built with mago — a TDD lane harness — so it went through a real red → green → review cycle.

The read/solve/drag layer is browser-coupled, so it isn’t covered by the unit tests. It was confirmed on the live puzzle: it read today’s walled 7×7, solved it, dragged the path in, and LinkedIn showed its win screen — scored with zero backtracks, LinkedIn’s tally of path erasures, because the whole line went in as one unbroken drag.

Full script

The readable, commented version — same result as the golfed paste above. This is what’s served here:

/*
 * LinkedIn Zip solver — paste into the browser console on
 * https://www.linkedin.com/games/zip/ and it solves the current puzzle.
 *
 * It reads the grid from the DOM — the numbered cells and which edges are
 * walled — finds the single path that visits every cell once and passes the
 * numbers in order, then draws it by dragging the pointer from cell to cell.
 */
(async () => {
	// The app samples the pointer once per animation frame, so the drag advances
	// one cell per frame; going faster makes it miss cells and mis-route.
	const FRAME = 28; // ms to wait after each frame
	const frame = () => new Promise((r) => requestAnimationFrame(() => setTimeout(r, FRAME)));

	const cells = [...document.querySelectorAll('[data-cell-idx]')].sort(
		(a, b) => a.dataset.cellIdx - b.dataset.cellIdx,
	);
	if (!cells.length) {
		console.error('%c🔗 Zip: no board found on this page.', 'color:#e11;font-weight:bold');
		return;
	}
	const n = Math.round(Math.sqrt(cells.length)); // N×N — read the size, never assume it

	// --- read the board ------------------------------------------------------
	// Each cell holds its number in aria-label "Number N". A wall is the ::after
	// pseudo-element of a child div carrying a thick (≥6px) border on one side —
	// the cell's own 1px borders are the grid lines, so the threshold skips them.
	// A wall is drawn on both cells it divides; collect one blocked-edge set.
	const key = (a, b) => (a < b ? `${a}-${b}` : `${b}-${a}`);
	const numbers = new Array(n * n).fill(0);
	const blocked = new Set();
	cells.forEach((el, i) => {
		const m = (el.getAttribute('aria-label') || '').match(/Number\s+(\d+)/);
		if (m) numbers[i] = +m[1];
		const r = (i / n) | 0,
			c = i % n;
		for (const kid of el.children) {
			const s = getComputedStyle(kid, '::after');
			if (s.content === 'none') continue;
			const wall = (side) => parseFloat(s[`border${side}Width`]) >= 6;
			if (wall('Right') && c < n - 1) blocked.add(key(i, i + 1));
			if (wall('Left') && c > 0) blocked.add(key(i, i - 1));
			if (wall('Bottom') && r < n - 1) blocked.add(key(i, i + n));
			if (wall('Top') && r > 0) blocked.add(key(i, i - n));
		}
	});

	// --- solve (identical logic to src/projects/zip/solver.mjs) --------------
	// Depth-first Hamiltonian path from the cell numbered 1: step only across
	// open edges, and a numbered cell is enterable only when its number is next.
	// A flood-fill connectivity check prunes any move that strands a cell.
	const maxNum = Math.max(0, ...numbers);
	const start = numbers.indexOf(1);
	const nbrs = (i) => {
		const r = (i / n) | 0,
			c = i % n,
			out = [];
		if (r > 0) out.push(i - n);
		if (r < n - 1) out.push(i + n);
		if (c > 0) out.push(i - 1);
		if (c < n - 1) out.push(i + 1);
		return out.filter((j) => !blocked.has(key(i, j)));
	};
	const visited = new Array(n * n).fill(false);
	const path = [];
	const connected = (cur) => {
		let total = 0;
		for (let i = 0; i < n * n; i++) if (!visited[i]) total++;
		if (!total) return true;
		const seen = new Uint8Array(n * n),
			stack = [];
		for (const nb of nbrs(cur)) if (!visited[nb] && !seen[nb]) (seen[nb] = 1), stack.push(nb);
		let count = 0;
		while (stack.length) {
			const x = stack.pop();
			count++;
			for (const nb of nbrs(x)) if (!visited[nb] && !seen[nb]) (seen[nb] = 1), stack.push(nb);
		}
		return count === total;
	};
	function dfs(cell, filled, need) {
		if (filled === n * n) return need === maxNum + 1;
		if (!connected(cell)) return false;
		for (const nb of nbrs(cell)) {
			if (visited[nb]) continue;
			const num = numbers[nb];
			if (num && num !== need) continue;
			visited[nb] = true;
			path.push(nb);
			if (dfs(nb, filled + 1, num ? need + 1 : need)) return true;
			visited[nb] = false;
			path.pop();
		}
		return false;
	}
	if (start < 0) {
		console.error('%c🔗 Zip: no cell numbered 1 was found.', 'color:#e11;font-weight:bold');
		return;
	}
	visited[start] = true;
	path.push(start);
	if (!dfs(start, 1, 2)) {
		console.error('%c🔗 Zip: no solution found for this board.', 'color:#e11;font-weight:bold');
		return;
	}

	// --- draw the path -------------------------------------------------------
	// Press on the start cell, then for each next cell leave the previous one and
	// enter the new one (pointer events plus mouse-event mirrors), and release on
	// the last. element.click() alone is ignored — the app wants the full drag.
	const at = (el) => {
		const r = el.getBoundingClientRect();
		return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
	};
	const fire = (el, Ctor, type, buttons) => {
		const p = at(el);
		el.dispatchEvent(
			new Ctor(type, {
				bubbles: true,
				cancelable: true,
				composed: true,
				view: window,
				pointerId: 1,
				pointerType: 'mouse',
				isPrimary: true,
				button: 0,
				buttons,
				clientX: p.x,
				clientY: p.y,
			}),
		);
	};
	const enter = (el, b) => {
		fire(el, PointerEvent, 'pointerover', b);
		fire(el, PointerEvent, 'pointerenter', b);
		fire(el, MouseEvent, 'mouseover', b);
		fire(el, MouseEvent, 'mouseenter', b);
		fire(el, PointerEvent, 'pointermove', b);
		fire(el, MouseEvent, 'mousemove', b);
	};
	const leave = (el) => {
		fire(el, PointerEvent, 'pointerout', 1);
		fire(el, PointerEvent, 'pointerleave', 1);
		fire(el, MouseEvent, 'mouseout', 1);
		fire(el, MouseEvent, 'mouseleave', 1);
	};

	const s0 = cells[path[0]];
	enter(s0, 0);
	fire(s0, PointerEvent, 'pointerdown', 1);
	fire(s0, MouseEvent, 'mousedown', 1);
	fire(s0, PointerEvent, 'pointermove', 1);
	fire(s0, MouseEvent, 'mousemove', 1);
	await frame();
	for (let k = 1; k < path.length; k++) {
		leave(cells[path[k - 1]]);
		enter(cells[path[k]], 1);
		await frame();
	}
	const last = cells[path[path.length - 1]];
	fire(last, PointerEvent, 'pointerup', 0);
	fire(last, MouseEvent, 'mouseup', 0);
	fire(last, MouseEvent, 'click', 0);

	console.log(
		`%c🔗 Zip ${n}×${n} drawn ✅ — ${path.length} cells, ${blocked.size} wall${blocked.size === 1 ? '' : 's'}`,
		'color:#16a34a;font-weight:bold;font-size:14px',
	);
})();