LinkedIn Queens Solver
Queens is LinkedIn’s daily logic puzzle: an N×N grid split into N colored regions. You place N queens so there’s exactly one per row, one per column, and one per color region — and no two queens may touch, not even diagonally. The script below reads the board, solves it, places the queens by driving the same taps you would, and verifies the result. On a fresh puzzle it wins instantly.
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 S = el => (el.ariaLabel || '').split(' ')[0]; // cell state: Empty|Cross|Queen
const Q = document.querySelectorAll('[data-cell-idx]'), n = Math.sqrt(Q.length), g = [], M = {};
for (const el of Q) { const [, k, r, c] = el.ariaLabel.match(/color ([^,]+), row (\d+), column (\d+)/i); (g[r-1] ??= [])[c-1] = k; M[r-1+','+(c-1)] = el; }
const s = [], U = new Set, R = new Set, P = (y, p) => { if (y == n) return 1; for (let x = 0; x < n; x++) if (!U.has(x) && Math.abs(x-p) > 1 && !R.has(g[y][x])) { U.add(x); R.add(g[y][x]); s[y] = x; if (P(y+1, x)) return 1; U.delete(x); R.delete(g[y][x]); } };
if (!P(0, -2)) return console.error('\u265b no solution');
const T = el => { const b = el.getBoundingClientRect(), o = { bubbles: 1, cancelable: 1, composed: 1, view: window, button: 0, clientX: b.x+b.width/2, clientY: b.y+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)); };
const C = async (el, t) => { for (let i = 0; i < 3 && S(el) != t; i++) { T(el); await new Promise(r => setTimeout(r, 45)); } };
for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (s[y] != x && S(M[y+','+x]) != 'Empty') await C(M[y+','+x], 'Empty');
for (let y = 0; y < n; y++) await C(M[y+','+s[y]], 'Queen');
})();
The one insight that makes it easy
You don’t need to read pixels to recover the color regions. Every cell exposes an accessibility label that spells everything out:
Empty cell of color Lime Yellow, row 1, column 1
So a cheap regex over [data-cell-idx] cells gives you the grid size and the
region of every square. The state (Empty / Cross / Queen) is just the first
word of the same label — which is also how the script verifies its own work.
The algorithm
The puzzle looks like N-queens, but the constraints are different:
- One per row, one per column, one per region.
- No touching — the “diagonal” rule is only about adjacency, not full chess
diagonals. Since we place exactly one queen per row and all columns are
distinct, two queens can only ever touch if they’re in consecutive rows.
That collapses the whole no-touch rule to a single check:
|col[r] − col[r−1]| ≥ 2.
With that reduction, a plain depth-first backtrack — prune on used column, used region, and consecutive-row adjacency — finds the answer in microseconds for the square boards LinkedIn ships (usually 8×8 to 11×11). Here’s the core:
function solve(regions) {
const n = regions.length;
const usedCols = new Set(), usedRegions = new Set(), sol = new Array(n);
const place = (row, prevCol) => {
if (row === n) return true;
for (let col = 0; col < n; col++) {
if (usedCols.has(col) || Math.abs(col - prevCol) <= 1) continue;
const region = regions[row][col];
if (usedRegions.has(region)) continue;
usedCols.add(col); usedRegions.add(region); sol[row] = col;
if (place(row + 1, col)) return true;
usedCols.delete(col); usedRegions.delete(region);
}
return false;
};
return place(0, -2) ? sol : null; // -2 so the adjacency guard never trips on row 0
}
Driving the board
Placing a queen 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 cell cycles
Empty → Cross → Queen → Empty. So a queen is two taps, and the script taps
until the label reads Queen. When it finishes, the board is solved and LinkedIn
jumps straight to the win screen.
How it was built
The solver’s core is a pure function unit-tested with node --test (the live 9×9
board, a generic 4×4, a 1×1, and two infeasible boards). It was written test-first
— a real red → green → review cycle — and the finished script was verified by
actually solving the live puzzle in the browser, not just by passing tests.
Full script
The readable, commented version — same result as the golfed paste above, plus
board validation, self-verification, and a console.table of the solution. This
is what’s served here:
/*
* LinkedIn Queens solver — paste into the browser console on
* https://www.linkedin.com/games/queens/ and it solves the current puzzle.
*/
(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 cells = () =>
[...document.querySelectorAll('[data-cell-idx]')].sort(
(a, b) => a.dataset.cellIdx - b.dataset.cellIdx,
);
const stateOf = (el) => (el.getAttribute('aria-label') || '').split(' ')[0];
function readBoard() {
const els = cells();
if (!els.length) throw new Error('No Queens board found on this page.');
const n = Math.round(Math.sqrt(els.length));
if (n * n !== els.length) throw new Error(`Board is not square (${els.length} cells).`);
const regions = Array.from({ length: n }, () => new Array(n));
const byRC = new Map();
for (const el of els) {
const m = (el.getAttribute('aria-label') || '').match(
/color ([^,]+), row (\d+), column (\d+)/i,
);
if (!m) throw new Error(`Cannot parse cell: "${el.getAttribute('aria-label')}"`);
const r = +m[2] - 1, c = +m[3] - 1;
regions[r][c] = m[1].trim();
byRC.set(`${r},${c}`, el);
}
return { n, regions, byRC };
}
function solve(regions) {
const n = regions.length;
const usedCols = new Set(), usedRegions = new Set(), sol = new Array(n);
const place = (row, prevCol) => {
if (row === n) return true;
for (let col = 0; col < n; col++) {
if (usedCols.has(col) || Math.abs(col - prevCol) <= 1) continue;
const region = regions[row][col];
if (usedRegions.has(region)) continue;
usedCols.add(col); usedRegions.add(region); sol[row] = col;
if (place(row + 1, col)) return true;
usedCols.delete(col); usedRegions.delete(region);
}
return false;
};
return place(0, -2) ? sol : null;
}
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));
}
async function setCell(el, target) {
for (let i = 0; i < 3 && stateOf(el) !== target; i++) { tap(el); await sleep(TAP_DELAY); }
return stateOf(el) === target;
}
const { n, regions, byRC } = readBoard();
const sol = solve(regions);
if (!sol) { console.error('♛ Queens: no solution found for this board.'); return; }
const inSolution = new Set(sol.map((c, r) => `${r},${c}`));
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
const key = `${r},${c}`;
if (!inSolution.has(key) && stateOf(byRC.get(key)) !== 'Empty') await setCell(byRC.get(key), 'Empty');
}
}
for (let r = 0; r < n; r++) await setCell(byRC.get(`${r},${sol[r]}`), 'Queen');
const placed = [];
for (let r = 0; r < n; r++)
for (let c = 0; c < n; c++)
if (stateOf(byRC.get(`${r},${c}`)) === 'Queen') placed.push([r, c]);
const ok = placed.length === n && placed.every(([r, c]) => sol[r] === c);
console.log(`♛ Queens ${n}×${n} ${ok ? 'solved ✅' : 'placement mismatch ⚠️'}`);
console.table(sol.map((c, r) => ({ row: r + 1, column: c + 1, region: regions[r][c] })));
})();