← Back to projects

LinkedIn Tango Solver

Active· javascript, puzzle, solver, backtracking

Tango is LinkedIn’s daily logic puzzle: a 6×6 grid you fill with Suns and Moons so that no more than two of the same symbol touch in any row or column, each row and column holds three of each, and cells joined by an = hold the same symbol while cells joined by an × hold opposites. The script below reads the board — symbols, locked cells, and the signs between cells — solves it, fills in the blanks by driving the same taps you would, and checks the finished grid against every rule. On a fresh puzzle it fills the whole board in one pass.

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 G = document.querySelector('[data-testid="interactive-grid"]');
const C = [...G.querySelectorAll('[data-cell-idx]')].sort((a, b) => a.dataset.cellIdx - b.dataset.cellIdx);
const n = Math.round(Math.sqrt(C.length)), h = n / 2;
const S = el => { const t = el.querySelector('svg[data-testid^="cell-"]')?.dataset.testid; return t == 'cell-zero' ? 0 : t == 'cell-one' ? 1 : null; };
const g = [], ed = [], K = [];
for (const el of C) { const i = +el.dataset.cellIdx, r = i / n | 0, c = i % n, lk = el.getAttribute('aria-disabled') == 'true'; (g[r] ??= [])[c] = lk ? S(el) : null; if (!lk) ed.push([r, c, el]); const b = el.getBoundingClientRect(), bx = b.left + b.width / 2, by = b.top + b.height / 2; for (const e of el.querySelectorAll('svg[data-testid^="edge-"]')) { const q = e.getBoundingClientRect(); let R = r, D = c; if (Math.abs(q.left + q.width / 2 - bx) >= Math.abs(q.top + q.height / 2 - by)) D += q.left + q.width / 2 - bx > 0 ? 1 : -1; else R += q.top + q.height / 2 - by > 0 ? 1 : -1; if (R < 0 || R >= n || D < 0 || D >= n) continue; K.push([r, c, R, D, /equal/i.test(e.dataset.testid + ' ' + (e.getAttribute('aria-label') || ''))]); } }
const cn = g.map(row => row.map(() => [])); for (const [ar, ac, br, bc, eq] of K) { cn[ar][ac].push([br, bc, eq]); cn[br][bc].push([ar, ac, eq]); }
const gr = [], ro = Array(n).fill(0), co = Array(n).fill(0);
const ok = (r, c, v) => { if (c >= 2 && gr[r][c - 1] == v && gr[r][c - 2] == v) return 0; if (r >= 2 && gr[r - 1][c] == v && gr[r - 2][c] == v) return 0; const A = ro[r] + v; if (A > h || c + 1 - A > h) return 0; const B = co[c] + v; if (B > h || r + 1 - B > h) return 0; for (const [nr, nc, eq] of cn[r][c]) { const w = gr[nr]?.[nc]; if (w == null) continue; if (eq ? w != v : w == v) return 0; } return 1; };
const dfs = i => { if (i == n * n) return 1; const r = i / n | 0, c = i % n, cand = g[r][c] != null ? [g[r][c]] : [0, 1]; for (const v of cand) { if (!ok(r, c, v)) continue; (gr[r] ??= [])[c] = v; ro[r] += v; co[c] += v; if (dfs(i + 1)) return 1; gr[r][c] = null; ro[r] -= v; co[c] -= v; } return 0; };
if (!dfs(0)) return console.error('🌗 Tango: no solution found for this board.');
const T = el => { const b = el.getBoundingClientRect(), o = { bubbles: 1, cancelable: 1, composed: 1, view: window, button: 0, clientX: b.left + b.width / 2, clientY: b.top + b.height / 2, pointerId: 1, pointerType: 'mouse', isPrimary: 1 }; for (const t of 'pointerdown mousedown pointerup mouseup click'.split(' ')) el.dispatchEvent(new PointerEvent(t, o)); };
for (const [r, c, el] of ed) for (let k = 0; k < 3 && S(el) != gr[r][c]; k++) { T(el); await new Promise(z => setTimeout(z, 45)); }
console.log(`🌗 Tango ${n}×${n} solved ✅`, gr.map(row => row.map(v => v ? '🌙' : '☀️').join('')).join(' / '));
})();

The one insight that makes it easy

Just like Queens, you don’t need to read pixels — the DOM spells everything out in data-testids. Each of the 36 [data-cell-idx] cells holds one svg whose id is the whole state:

cell-zero  → Sun      cell-one → Moon      cell-empty → blank

Locked givens carry aria-disabled="true", so the reader knows which cells it may touch. The genuinely new part is the signs between cells. A sign renders as an extra edge--prefixed svg tucked inside one of the two cells it joins — edge-equal for =, and its counterpart for × (the reader treats any non-equal edge as ×) — but the id only tells you the kind of sign, not which pair it spans. Rather than reverse LinkedIn’s hashed CSS classes to learn the sign’s side, the script reads its on-screen box: the offset from the owning cell’s centre points at the neighbour (bigger axis wins, its sign picks the direction). Geometry is immune to class-name churn, so the reader keeps working when the styles change.

The algorithm

Strip away the theme and Tango is a binary puzzle — every cell is a 0 (Sun) or a 1 (Moon) — with three rules:

A plain depth-first backtrack over the cells in row-major order settles it instantly. Because only the left and up neighbours are already placed when you reach a cell, three cheap checks prune every dead branch: a run of three ending at the current cell (the triple rule), the counts placed so far in this row and column (neither symbol may exceed three), and any sign joining the cell to an already-placed neighbour. Here’s the core:

function canPlace(r, c, v) {
  if (c >= 2 && g[r][c - 1] === v && g[r][c - 2] === v) return false; // no h-triple
  if (r >= 2 && g[r - 1][c] === v && g[r - 2][c] === v) return false; // no v-triple
  const rowOnesAfter = rowOnes[r] + (v === 1 ? 1 : 0);
  if (rowOnesAfter > half || c + 1 - rowOnesAfter > half) return false; // ≤ half each
  const colOnesAfter = colOnes[c] + (v === 1 ? 1 : 0);
  if (colOnesAfter > half || r + 1 - colOnesAfter > half) return false;
  for (const nb of cons[r][c]) {          // = / × to an already-placed neighbour
    const nv = g[nb.r][nb.c];
    if (nv !== null && (nb.equal ? nv !== v : nv === v)) return false;
  }
  return true;
}

The same search doubles as a uniqueness check: run it in counting mode and a real Tango board returns exactly one solution. If the script ever reads a board that solves more than one way, it warns — that’s the tell that a sign was misread, and it’s why the sign geometry has to be right.

Driving the board

Filling a cell is the fiddly part. element.click() is ignored — the game listens to lower-level pointer events. A full pointerdown → mousedown → pointerup → mouseup → click sequence registers as one tap, and each editable cell cycles blank → symbol → other symbol → blank. Instead of hard-coding which symbol comes first, the script taps up to three times and stops the moment the cell shows the symbol it wants — order doesn’t matter, and locked givens are never touched. Once the last blank is filled the grid is complete and consistent — which is what LinkedIn checks to show its win screen.

How it was built

The solver’s core is a pure function unit-tested with node --test — hand-built boards that exercise each rule (the = and × signs, the triple rule, the balanced-line counts, the givens), three infeasible boards that must return null, and a uniqueness counter that confirms a well-formed board has exactly one answer. It was written test-first, so it went through a real red → green → review cycle.

The read/place/verify layer is browser-coupled, so it isn’t covered by the unit tests: it recovers the = / × sign orientation from on-screen geometry, reads the Sun/Moon symbols from the cells’ test ids, and drives the board with synthetic taps. That layer was confirmed on the live puzzle — the script read the board, recovered both = signs’ orientation from geometry, filled the grid to the puzzle’s one solution, and LinkedIn showed its win screen.

Full script

The readable, commented version — same result as the golfed paste above, plus a uniqueness check, self-verification, and a console.table of the solution. This is what’s served here:

/*
 * LinkedIn Tango solver — paste into the browser console on
 * https://www.linkedin.com/games/tango/ and it solves the current puzzle.
 *
 * It reads the 6×6 board from the DOM — each cell's symbol, which cells are
 * locked, and the "=" / "×" signs between cells (whose orientation is recovered
 * from on-screen geometry) — solves it with a tiny backtracking search, then
 * fills the empty cells by driving the same pointer events a real tap fires, and
 * finally verifies the placed grid obeys every rule.
 */
(async () => {
	const TAP_DELAY = 45; // ms between synthetic taps, so the app can re-render
	const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

	const grid = () => document.querySelector('[data-testid="interactive-grid"]');
	const cells = () =>
		[...(grid()?.querySelectorAll('[data-cell-idx]') || [])].sort(
			(a, b) => a.dataset.cellIdx - b.dataset.cellIdx,
		);

	// A cell's symbol comes from its inner svg's test id: cell-zero = Sun (0),
	// cell-one = Moon (1), cell-empty = blank (null). The edge svgs (edge-equal /
	// edge-cross) use an "edge-" prefix, so they never collide with this.
	const symbolOf = (cell) => {
		const t = cell.querySelector('svg[data-testid^="cell-"]')?.getAttribute('data-testid');
		return t === 'cell-zero' ? 0 : t === 'cell-one' ? 1 : null;
	};

	// Read grid size, the given/locked symbols, and the =/× signs from the DOM.
	// Each sign is a child svg of one cell; its geometric offset from that cell's
	// centre tells us which neighbour it joins (the bigger axis wins, its sign
	// picks the direction) — robust to LinkedIn's opaque, hashed CSS classes.
	function readBoard() {
		const els = cells();
		if (!els.length) throw new Error('No Tango board found on this page.');
		const n = Math.round(Math.sqrt(els.length));
		if (n * n !== els.length || n % 2 !== 0) throw new Error(`Unexpected Tango grid (${els.length} cells).`);

		const given = Array.from({ length: n }, () => new Array(n).fill(null));
		const byRC = new Map();
		const editable = new Set();
		const constraints = [];
		const seen = new Set();

		for (const el of els) {
			const idx = +el.dataset.cellIdx;
			const r = Math.floor(idx / n),
				c = idx % n;
			byRC.set(`${r},${c}`, el);
			// Only LOCKED cells are clues; anything editable (even a symbol a
			// previous run or a wrong guess left behind) is treated as blank.
			const locked = el.getAttribute('aria-disabled') === 'true';
			given[r][c] = locked ? symbolOf(el) : null;
			if (!locked) editable.add(`${r},${c}`);

			const cr = el.getBoundingClientRect();
			const ccx = cr.left + cr.width / 2,
				ccy = cr.top + cr.height / 2;
			for (const e of el.querySelectorAll('svg[data-testid^="edge-"]')) {
				const er = e.getBoundingClientRect();
				const dx = er.left + er.width / 2 - ccx;
				const dy = er.top + er.height / 2 - ccy;
				let nr = r,
					nc = c;
				if (Math.abs(dx) >= Math.abs(dy)) nc += dx > 0 ? 1 : -1;
				else nr += dy > 0 ? 1 : -1;
				if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
				const nIdx = nr * n + nc;
				const key = idx < nIdx ? `${idx}-${nIdx}` : `${nIdx}-${idx}`;
				if (seen.has(key)) continue; // one sign per border, even if both cells carry it
				seen.add(key);
				const tag = `${e.getAttribute('data-testid') || ''} ${e.getAttribute('aria-label') || ''}`;
				constraints.push({ a: [r, c], b: [nr, nc], equal: /equal/i.test(tag) });
			}
		}
		return { n, given, constraints, byRC, editable };
	}

	// --- pure solver (identical to src/projects/tango/solver.mjs) -------------
	// One depth-first search over cells in row-major order. `count` mode tallies
	// solutions up to `cap`; solve mode captures and returns the first one.
	function search(puzzle, count, cap = 2) {
		const n = puzzle?.n | 0;
		if (!n || n % 2 !== 0) return count ? 0 : null;
		const half = n / 2;
		const given = puzzle.given || [];

		const cons = Array.from({ length: n }, () => Array.from({ length: n }, () => []));
		for (const k of puzzle.constraints || []) {
			const [ar, ac] = k.a;
			const [br, bc] = k.b;
			cons[ar][ac].push({ r: br, c: bc, equal: k.equal });
			cons[br][bc].push({ r: ar, c: ac, equal: k.equal });
		}

		const g = Array.from({ length: n }, () => new Array(n).fill(null));
		const rowOnes = new Array(n).fill(0);
		const colOnes = new Array(n).fill(0);

		function canPlace(r, c, v) {
			if (c >= 2 && g[r][c - 1] === v && g[r][c - 2] === v) return false;
			if (r >= 2 && g[r - 1][c] === v && g[r - 2][c] === v) return false;
			const rowOnesAfter = rowOnes[r] + (v === 1 ? 1 : 0);
			if (rowOnesAfter > half || c + 1 - rowOnesAfter > half) return false;
			const colOnesAfter = colOnes[c] + (v === 1 ? 1 : 0);
			if (colOnesAfter > half || r + 1 - colOnesAfter > half) return false;
			for (const nb of cons[r][c]) {
				const nv = g[nb.r][nb.c];
				if (nv === null) continue;
				if (nb.equal ? nv !== v : nv === v) return false;
			}
			return true;
		}

		let solution = null;
		let total = 0;
		function dfs(idx) {
			if (idx === n * n) {
				total++;
				if (!count && !solution) solution = g.map((row) => row.slice());
				return;
			}
			const r = (idx / n) | 0;
			const c = idx % n;
			const candidates = given[r]?.[c] != null ? [given[r][c]] : [0, 1];
			for (const v of candidates) {
				if (!canPlace(r, c, v)) continue;
				g[r][c] = v;
				if (v === 1) {
					rowOnes[r]++;
					colOnes[c]++;
				}
				dfs(idx + 1);
				g[r][c] = null;
				if (v === 1) {
					rowOnes[r]--;
					colOnes[c]--;
				}
				if (count ? total >= cap : total >= 1) return;
			}
		}

		dfs(0);
		return count ? total : solution;
	}
	const solveTango = (p) => search(p, false);
	const countTangoSolutions = (p, cap = 2) => search(p, true, cap);

	// A cell cycles blank -> symbol -> other symbol -> blank on each tap. Fire the
	// full pointer sequence because element.click() alone is ignored by the game.
	function tap(el) {
		const r = el.getBoundingClientRect();
		const o = {
			bubbles: true,
			cancelable: true,
			composed: true,
			view: window,
			button: 0,
			clientX: r.left + r.width / 2,
			clientY: r.top + r.height / 2,
			pointerId: 1,
			pointerType: 'mouse',
			isPrimary: true,
		};
		el.dispatchEvent(new PointerEvent('pointerdown', o));
		el.dispatchEvent(new MouseEvent('mousedown', o));
		el.dispatchEvent(new PointerEvent('pointerup', o));
		el.dispatchEvent(new MouseEvent('mouseup', o));
		el.dispatchEvent(new MouseEvent('click', o));
	}

	// Tap until the cell shows `target` (0 or 1). At most 3 taps around the cycle
	// reach any symbol from any state, so we never need to know the tap order.
	async function setCell(cell, target) {
		for (let i = 0; i < 3 && symbolOf(cell) !== target; i++) {
			tap(cell);
			await sleep(TAP_DELAY);
		}
		return symbolOf(cell) === target;
	}

	// --- run ------------------------------------------------------------------
	const board = readBoard();
	const sol = solveTango(board);
	if (!sol) {
		console.error('%c🌗 Tango: no solution found for this board.', 'color:#e11;font-weight:bold');
		return;
	}
	const count = countTangoSolutions(board, 2);
	if (count !== 1) {
		console.warn(
			`🌗 Tango: the board as read has ${count >= 2 ? '≥2' : count} solutions — a sign may have been misread. Placing one valid solution anyway.`,
		);
	}

	// Fill every editable cell to its solved symbol (locked givens are skipped).
	for (let r = 0; r < board.n; r++) {
		for (let c = 0; c < board.n; c++) {
			if (board.editable.has(`${r},${c}`)) await setCell(board.byRC.get(`${r},${c}`), sol[r][c]);
		}
	}

	// Verify what actually landed on the board matches the solved grid.
	let ok = true;
	for (let r = 0; r < board.n && ok; r++)
		for (let c = 0; c < board.n && ok; c++) if (symbolOf(board.byRC.get(`${r},${c}`)) !== sol[r][c]) ok = false;

	const face = (v) => (v === 0 ? '☀️' : '🌙');
	console.log(
		`%c🌗 Tango ${board.n}×${board.n} ${ok ? 'solved ✅' : 'placement mismatch ⚠️'}`,
		`color:${ok ? '#16a34a' : '#d97706'};font-weight:bold;font-size:14px`,
	);
	console.table(sol.map((row) => row.map(face).join(' ')));
})();