← Back to projects

LinkedIn Patches Solver

Active· javascript, puzzle, solver, backtracking

Patches is LinkedIn’s daily tiling puzzle — a Shikaku variant. Cut the N×N grid into rectangles so that each rectangle holds exactly one clue tile, its shape matches the clue — a square, a wide rectangle, a tall rectangle, or any shape at all — and, when the clue carries a number, its area equals that number. Some boards leave clue tiles unnumbered: the shape is fixed but the size is yours to deduce. You draw each rectangle by dragging one corner to the opposite corner. This script reads the clues, solves the tiling, and performs every drag.

Script

Open the puzzle, open the console (F12), and paste this — keep the tab in the foreground so the drags register. 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;
const SH = { square: 'square', 'wide rectangle': 'wide', 'tall rectangle': 'tall', freeform: 'free' };
const clues = [];
C.forEach((el, i) => { const m = (el.getAttribute('aria-label') || '').match(/(square|wide rectangle|tall rectangle|freeform) clue(?:,\s*(\d+))?/i); if (m) clues.push({ cell: i, area: m[2] ? +m[2] : null, shape: SH[m[1].toLowerCase()] }); });
const mn = c => c.area || (c.shape === 'wide' || c.shape === 'tall' ? 2 : 1);
const MT = clues.reduce((s, c) => s + mn(c), 0);
if (!clues.length || MT > N || (clues.every(c => c.area) && MT !== N)) return console.error('🧩 Patches: clues cannot tile the grid.');
const ok = (s, h, w) => s === 'square' ? h === w : s === 'wide' ? w > h : s === 'tall' ? h > w : true;
const CA = new Uint8Array(N); clues.forEach(c => CA[c.cell] = 1);
const other = (t, l, h, w, s) => { for (let y = t; y < t + h; y++) for (let x = l; x < l + w; x++) if (CA[y * n + x] && y * n + x !== s) return true; return false; };
const cand = clues.map(cl => { const cr = cl.cell / n | 0, cc = cl.cell % n, mx = cl.area || N - MT + mn(cl), R = []; for (let h = 1; h <= n; h++) for (let w = 1; w <= n; w++) { if (cl.area ? h * w !== cl.area : h * w > mx) continue; if (!ok(cl.shape, h, w)) continue; for (let t = Math.max(0, cr - h + 1); t <= Math.min(cr, n - h); t++) for (let l = Math.max(0, cc - w + 1); l <= Math.min(cc, n - w); l++) if (!other(t, l, h, w, cl.cell)) R.push({ clue: cl.cell, top: t, left: l, bottom: t + h - 1, right: l + w - 1 }); } return R; });
const occ = new Int8Array(N).fill(-1), placed = Array(clues.length).fill(null);
const fits = r => { for (let y = r.top; y <= r.bottom; y++) for (let x = r.left; x <= r.right; x++) if (occ[y * n + x] !== -1) return false; return true; };
const paint = (r, id) => { for (let y = r.top; y <= r.bottom; y++) for (let x = r.left; x <= r.right; x++) occ[y * n + x] = id; };
const dfs = (d, cov) => { if (d === clues.length) return cov === N; let p = -1, ps = null; const re = new Uint8Array(N); for (let i = 0; i < clues.length; i++) { if (placed[i]) continue; const o = cand[i].filter(fits); if (!o.length) return false; for (const r of o) for (let y = r.top; y <= r.bottom; y++) for (let x = r.left; x <= r.right; x++) re[y * n + x] = 1; if (!ps || o.length < ps.length) p = i, ps = o; } for (let i = 0; i < N; i++) if (occ[i] === -1 && !re[i]) return false; for (const r of ps) { paint(r, p); placed[p] = r; if (dfs(d + 1, cov + (r.bottom - r.top + 1) * (r.right - r.left + 1))) return true; paint(r, -1); placed[p] = null; } return false; };
if (!dfs(0, 0)) return console.error('🧩 Patches: no tiling found.');
const F = (el, X, t, b, x, y) => el.dispatchEvent(new X(t, { bubbles: 1, cancelable: 1, composed: 1, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: 1, button: 0, buttons: b, clientX: x, clientY: y }));
const ctr = el => { const r = el.getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; };
const fr = () => new Promise(r => requestAnimationFrame(() => setTimeout(r, 28)));
const ends = r => { const cr = r.clue / n | 0, cc = r.clue % n, cl = ([y, x]) => y === cr && x === cc, D = [[[r.top, r.left], [r.bottom, r.right]], [[r.top, r.right], [r.bottom, r.left]]]; for (const d of D) if (!cl(d[0]) && !cl(d[1])) return d; for (const d of D) if (!cl(d[0])) return d; for (const d of D) if (!cl(d[1])) return [d[1], d[0]]; return D[0]; };
for (const r of placed) {
  if (r.top === r.bottom && r.left === r.right) { const el = C[r.top * n + r.left], [x, y] = ctr(el); F(el, PointerEvent, 'pointerdown', 1, x, y); F(el, MouseEvent, 'mousedown', 1, x, y); await fr(); F(el, PointerEvent, 'pointerup', 0, x, y); F(el, MouseEvent, 'mouseup', 0, x, y); F(el, MouseEvent, 'click', 0, x, y); await fr(); continue; }
  const [[ar, ac], [tr, tc]] = ends(r), A = C[ar * n + ac], [ax, ay] = ctr(A), [tx, ty] = ctr(C[tr * n + tc]);
  F(A, PointerEvent, 'pointerover', 0, ax, ay); F(A, PointerEvent, 'pointerenter', 0, ax, ay); F(A, PointerEvent, 'pointerdown', 1, ax, ay); F(A, MouseEvent, 'mousedown', 1, ax, ay);
  try { A.setPointerCapture(1); } catch (e) {} await fr();
  const st = Math.max(Math.abs(tr - ar), Math.abs(tc - ac)) + 2;
  for (let s = 1; s <= st; s++) { const x = ax + (tx - ax) * s / st, y = ay + (ty - ay) * s / st; F(A, PointerEvent, 'pointermove', 1, x, y); F(A, MouseEvent, 'mousemove', 1, x, y); const u = document.elementFromPoint(x, y); if (u) { F(u, PointerEvent, 'pointerover', 1, x, y); F(u, PointerEvent, 'pointermove', 1, x, y); F(u, MouseEvent, 'mousemove', 1, x, y); } await fr(); }
  F(A, PointerEvent, 'pointerup', 0, tx, ty); F(A, MouseEvent, 'mouseup', 0, tx, ty); const u = document.elementFromPoint(tx, ty); if (u) F(u, MouseEvent, 'click', 0, tx, ty); await fr();
}
console.log(`🧩 Patches ${n}×${n} drawn ✅ — ${placed.length} patches`);
})();

The one insight that makes it easy

The board is entirely described in the DOM, and better still, in plain English. Every clue cell’s aria-label reads exactly like "Row 2, column 3, wide rectangle clue, 6 cells" — the row, the column, the required shape, and the area. An unnumbered shape tile reads the same minus the trailing cell count, so one optional regex group covers both. There’s no colour-matching or pixel-reading: a regex over the aria-labels hands you the whole puzzle.

The algorithm

With the clues in hand it’s a rectangle-tiling search. For each numbered clue, enumerate every rectangle whose area equals the number and whose proportions match the shape — each factor pair h × w = area, filtered by square (h = w), wide (w > h), or tall (h > w) — positioned every way that still covers the clue and fits the grid. An unnumbered clue admits every shape-matching rectangle instead, capped at whatever the other clues’ minimum sizes leave room for. Either way a candidate may never swallow a second clue tile — that tile’s own rectangle would have to overlap it. Then place one rectangle per clue by backtracking over an occupancy grid, always extending the clue with the fewest remaining options first.

On a fully-numbered board the areas sum to N², so any placement with no overlaps is automatically a complete tiling. Unnumbered clues lose that guarantee — every clue can be placed and holes remain — so the search demands full coverage explicitly, and it kills a branch early whenever some empty cell is beyond the reach of every rectangle still open.

Driving the board

Each patch is drawn with one drag from a corner to the opposite corner — the app fills the bounding box in the clue’s colour. Two things had to be reverse- engineered. First, the drag must not start on a clue cell (starting there does nothing), so the script anchors on a non-clue corner — every patch of two or more cells has one — and releases on its diagonal opposite. (A single-cell patch, possible for an unnumbered square or freeform tile, has no such corner; it gets a plain tap instead.) Second, the grid takes pointer capture on press, so every pointermove must be dispatched on the anchor cell with the pointer position stepped toward the target (one step per animation frame). Firing the moves on each crossed cell instead — the way Zip’s per-cell grid wants — is silently ignored: the subtle trap that makes a naïve port animate the endpoints but draw nothing.

How it was built

The solver’s core is a pure function unit-tested with node --test: a small board with a unique tiling, a shape-discriminating test (the same 2×2 grid tiles as two rows under “wide” but two columns under “tall”, proving the shape constraint is honoured), two infeasible boards returning null (an impossible shape, and areas that don’t fill the grid), unnumbered-clue boards (a free clue forced to exactly the leftover area, shapes holding without a number, and a mixed numbered/unnumbered 5×5), and the live 8×8 board checked to a rule-valid tiling — every rectangle’s area, shape, clue-containment, and exactly-once coverage.

The read/solve/drag layer is browser-coupled, so it isn’t covered by the unit tests. The drag mechanic was confirmed on the live board — a corner-to-corner drag fills the patch — and it reuses the exact synthetic-drag model that solved Zip.

Full script

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

/*
 * LinkedIn Patches solver — paste into the browser console on
 * https://www.linkedin.com/games/patches/ and it solves the current puzzle.
 *
 * Patches is a Shikaku variant: tile the whole grid with rectangles, one per
 * clue tile, each rectangle's shape matching the clue (square / wide / tall /
 * any). A numbered clue also fixes its rectangle's area; an unnumbered clue
 * tile takes any size. This reads the clues from the board, solves the tiling,
 * and draws each rectangle by dragging one corner to the opposite corner. Keep
 * this tab in the foreground while it runs.
 */
(async () => {
	// The app updates its drag preview once per animation frame, so each drag
	// steps the pointer toward the target one step per frame.
	const FRAME = 28;
	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🧩 Patches: 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 clues ------------------------------------------------------
	// Each clue cell's aria-label reads "Row R, column C, <shape> clue, K cells",
	// where <shape> is "square" / "wide rectangle" / "tall rectangle" / "freeform".
	// Unnumbered clue tiles carry the shape but no ", K cells" part — their patch
	// may be any size that matches the shape.
	const clues = [];
	cells.forEach((el, i) => {
		const label = el.getAttribute('aria-label') || '';
		const m = label.match(
			/(square|wide rectangle|tall rectangle|freeform)\s+clue(?:,\s*(\d+)\s*cell)?/i,
		);
		if (!m) return;
		const shape = { square: 'square', 'wide rectangle': 'wide', 'tall rectangle': 'tall', freeform: 'free' }[
			m[1].toLowerCase()
		];
		clues.push({ cell: i, area: m[2] ? +m[2] : null, shape });
	});
	if (!clues.length) {
		console.error('%c🧩 Patches: found no clue tiles on the board.', 'color:#e11;font-weight:bold');
		return;
	}

	// --- solve (identical logic to src/projects/patches/solver.mjs) ----------
	const solvePatches = (n, clues) => {
		const N = n * n;
		const minArea = (cl) =>
			cl.area ? cl.area | 0 : cl.shape === 'wide' || cl.shape === 'tall' ? 2 : 1;
		const minTotal = clues.reduce((s, c) => s + minArea(c), 0);
		if (minTotal > N || (clues.every((c) => c.area) && minTotal !== N)) return null;
		const shapeOk = (shape, h, w) =>
			shape === 'square' ? h === w : shape === 'wide' ? w > h : shape === 'tall' ? h > w : true;
		const clueAt = new Uint8Array(N);
		for (const c of clues) clueAt[c.cell] = 1;
		const holdsOtherClue = (top, left, h, w, self) => {
			for (let r = top; r < top + h; r++)
				for (let c = left; c < left + w; c++) {
					const i = r * n + c;
					if (clueAt[i] && i !== self) return true;
				}
			return false;
		};
		const candidates = clues.map((cl) => {
			const cr = (cl.cell / n) | 0,
				cc = cl.cell % n,
				rects = [];
			const maxArea = cl.area ? cl.area | 0 : N - (minTotal - minArea(cl));
			for (let h = 1; h <= n; h++)
				for (let w = 1; w <= n; w++) {
					if (cl.area ? h * w !== cl.area : h * w > maxArea) continue;
					if (!shapeOk(cl.shape, h, w)) continue;
					for (let top = Math.max(0, cr - h + 1); top <= Math.min(cr, n - h); top++)
						for (let left = Math.max(0, cc - w + 1); left <= Math.min(cc, n - w); left++)
							if (!holdsOtherClue(top, left, h, w, cl.cell))
								rects.push({ clue: cl.cell, top, left, bottom: top + h - 1, right: left + w - 1 });
				}
			return rects;
		});
		const occ = new Int8Array(N).fill(-1);
		const placed = new Array(clues.length).fill(null);
		const fits = (rect) => {
			for (let r = rect.top; r <= rect.bottom; r++)
				for (let c = rect.left; c <= rect.right; c++) if (occ[r * n + c] !== -1) return false;
			return true;
		};
		const paint = (rect, id) => {
			for (let r = rect.top; r <= rect.bottom; r++)
				for (let c = rect.left; c <= rect.right; c++) occ[r * n + c] = id;
		};
		function dfs(done, covered) {
			if (done === clues.length) return covered === N;
			let pick = -1,
				pickRects = null;
			const reach = new Uint8Array(N);
			for (let i = 0; i < clues.length; i++) {
				if (placed[i]) continue;
				const open = candidates[i].filter(fits);
				if (open.length === 0) return false;
				for (const rect of open)
					for (let r = rect.top; r <= rect.bottom; r++)
						for (let c = rect.left; c <= rect.right; c++) reach[r * n + c] = 1;
				if (!pickRects || open.length < pickRects.length) (pick = i), (pickRects = open);
			}
			for (let i = 0; i < N; i++) if (occ[i] === -1 && !reach[i]) return false;
			for (const rect of pickRects) {
				paint(rect, pick);
				placed[pick] = rect;
				const area = (rect.bottom - rect.top + 1) * (rect.right - rect.left + 1);
				if (dfs(done + 1, covered + area)) return true;
				paint(rect, -1);
				placed[pick] = null;
			}
			return false;
		}
		return dfs(0, 0) ? placed.slice() : null;
	};

	const solution = solvePatches(n, clues);
	if (!solution) {
		const numbered = clues.filter((c) => c.area).length;
		console.error(
			`%c🧩 Patches: no tiling found (${numbered} numbered + ${clues.length - numbered} unnumbered clues).`,
			'color:#e11;font-weight:bold',
		);
		return;
	}

	// --- draw the rectangles -------------------------------------------------
	// Draw each rectangle by dragging one corner to the opposite corner; the app
	// fills the bounding box. Two things matter. The anchor corner must NOT be a
	// clue cell — starting a drag there does nothing. And because the app takes
	// pointer capture on press, the move events must be dispatched on the ANCHOR
	// with the pointer position stepping toward the target; dispatching them on
	// each crossed cell instead (as a hover-tracked grid would want) is ignored.
	const center = (el) => {
		const r = el.getBoundingClientRect();
		return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
	};
	const fire = (el, Ctor, type, buttons, x, y) => {
		el.dispatchEvent(
			new Ctor(type, {
				bubbles: true,
				cancelable: true,
				composed: true,
				view: window,
				pointerId: 1,
				pointerType: 'mouse',
				isPrimary: true,
				button: 0,
				buttons,
				clientX: x,
				clientY: y,
			}),
		);
	};

	// [anchor, target] diagonal corner cells, anchor kept off the clue cell.
	const endpoints = (rect) => {
		const cr = (rect.clue / n) | 0,
			cc = rect.clue % n;
		const isClue = ([r, c]) => r === cr && c === cc;
		const diags = [
			[[rect.top, rect.left], [rect.bottom, rect.right]],
			[[rect.top, rect.right], [rect.bottom, rect.left]],
		];
		for (const d of diags) if (!isClue(d[0]) && !isClue(d[1])) return d;
		for (const d of diags) if (!isClue(d[0])) return d;
		for (const d of diags) if (!isClue(d[1])) return [d[1], d[0]];
		return diags[0];
	};

	for (const rect of solution) {
		// A single-cell patch (possible for an unnumbered square/freeform clue)
		// has no non-clue corner to drag from — tap the cell instead.
		if (rect.top === rect.bottom && rect.left === rect.right) {
			const el = cells[rect.top * n + rect.left];
			const p = center(el);
			fire(el, PointerEvent, 'pointerdown', 1, p.x, p.y);
			fire(el, MouseEvent, 'mousedown', 1, p.x, p.y);
			await frame();
			fire(el, PointerEvent, 'pointerup', 0, p.x, p.y);
			fire(el, MouseEvent, 'mouseup', 0, p.x, p.y);
			fire(el, MouseEvent, 'click', 0, p.x, p.y);
			await frame();
			continue;
		}
		const [[ar, ac], [tr, tc]] = endpoints(rect);
		const anchor = cells[ar * n + ac];
		const a = center(anchor),
			t = center(cells[tr * n + tc]);
		fire(anchor, PointerEvent, 'pointerover', 0, a.x, a.y);
		fire(anchor, PointerEvent, 'pointerenter', 0, a.x, a.y);
		fire(anchor, PointerEvent, 'pointerdown', 1, a.x, a.y);
		fire(anchor, MouseEvent, 'mousedown', 1, a.x, a.y);
		try {
			anchor.setPointerCapture(1);
		} catch (e) {}
		await frame();
		// Step from the anchor to the opposite corner, one step per frame, firing
		// on the anchor (for the captured handler) and on whatever cell is under
		// the pointer (for hover tracking).
		const steps = Math.max(Math.abs(tr - ar), Math.abs(tc - ac)) + 2;
		for (let s = 1; s <= steps; s++) {
			const x = a.x + ((t.x - a.x) * s) / steps,
				y = a.y + ((t.y - a.y) * s) / steps;
			fire(anchor, PointerEvent, 'pointermove', 1, x, y);
			fire(anchor, MouseEvent, 'mousemove', 1, x, y);
			const under = document.elementFromPoint(x, y);
			if (under) {
				fire(under, PointerEvent, 'pointerover', 1, x, y);
				fire(under, PointerEvent, 'pointermove', 1, x, y);
				fire(under, MouseEvent, 'mousemove', 1, x, y);
			}
			await frame();
		}
		fire(anchor, PointerEvent, 'pointerup', 0, t.x, t.y);
		fire(anchor, MouseEvent, 'mouseup', 0, t.x, t.y);
		const end = document.elementFromPoint(t.x, t.y);
		if (end) fire(end, MouseEvent, 'click', 0, t.x, t.y);
		await frame();
	}

	console.log(
		`%c🧩 Patches ${n}×${n} drawn ✅ — ${solution.length} patches`,
		'color:#16a34a;font-weight:bold;font-size:14px',
	);
})();