English · Deutsch
▶️ Play now: https://ilianp.github.io/Queens-clone/
A clone of the LinkedIn game Queens – plain HTML/CSS/JavaScript, no build step, runs straight off GitHub Pages and is built for phone and desktop alike.
On an N × N board split into N colour regions you place N queens (👑):
- exactly one queen per row,
- exactly one per column,
- exactly one per colour region,
- and no two queens may touch – not even diagonally.
Every generated puzzle has exactly one solution and is solvable by pure logic (no guessing needed).
- Tapping cycles a cell: empty → dot (ruled out) → 👑 → empty.
- New game generates a fresh puzzle.
- 🔊 / 🔇 at the top mutes and unmutes the sound effects directly, without going through the settings.
- Reset clears the current board.
- Check briefly reports whether your board is still error-free – just a green “✓ No mistakes” or a red “✗ There are mistakes”, without revealing where a mistake is and without suggesting the next move (that is the hint's job). A mistake is any rule broken on the current board and any departure from the unique solution before a rule breaks: a queen that isn't part of the solution counts, as does a dot on a cell where the solution needs a queen. Each check costs one second per row (5 s on a 5×5 up to 14 s on a 14×14); checking an unchanged board again, or an empty one, is free.
- Mistakes (touching / colliding queens) are marked red.
- A row, column or colour region in which every cell is ruled out and no queen stands is outlined in pulsing red – no queen can go there any more, so something is wrong.
-
Language: English, German, French, Spanish, Portuguese or Russian; the default is Automatic (browser) – if the browser language matches no available translation, English is used. Switching the language reloads the page (you are asked first if a game is in progress); a solved but not-yet-submitted result is not lost in the process.
-
Board size: 5 to 14, on every screen. From 12 up only hard puzzles are possible – a board that size is inherently hard and “easy”/“medium” puzzles of it essentially don't exist – so the difficulty is fixed to Hard there. A large board on a small screen is not blocked, just flagged: the settings measure what a cell would actually render at on your screen and say so when it gets cramped (below 28 px), e.g. “a cell would be only about 26 px”. Whether that is too small is your call, not the game's – above 12 columns the board also claims a bit more of the window to help.
-
Difficulty:
- Easy – solvable with “only one cell left” deductions alone.
- Medium – additionally needs row/column ↔ region deductions.
- Hard – needs a look-ahead (contradiction) deduction.
Up to board size 11 the difficulty is independent of the board size. Since the puzzles come from precomputed pools (see below), the chosen level is always hit exactly – including on large boards, where a very easy puzzle would be rare if it were generated live.
-
Quick mode: Placing a queen automatically dots every cell it rules out: the whole row, column, colour region and the neighbouring cells.
-
Sound: Short, discreet sound effects for placing a queen, dotting, hints and solving. Can be muted here or directly via the 🔊/🔇 icon at the top.
-
Voice control (beta, German only): Steer the game by speaking. The commands are a German grammar (spelling alphabet, spoken number words, “außer”) and not translated labels, so the option is only available while the interface is set to German. Cells are named like on a chessboard – a column letter (A…, from the left) plus a row number (1…, from the top), e.g. “C4” or spelled out “Cäsar vier”. A panel offers a 🎤 button to start listening, an ⓘ button with a short command tutorial, and the last thing recognised; while the mode is active the coordinates are also shown on the board – either small in each cell's corner or (sub-option “Koordinaten groß am Rand”) large as a chessboard-style ruler along the edge. A bare “C4” cycles the cell like a tap; the sub-option “Koordinate setzt zuerst eine Dame” makes it empty → 👑 → dot → empty. It uses the browser's built-in speech recognition (Web Speech API) – no extra dependency and no server, but currently only in Chrome/Edge and with microphone permission. Where recognition is missing the option is disabled and the game runs on unchanged.
Top right in the settings, level with the heading, sits a small QR button: it
opens the game's web address as a QR code, so someone next to you can point their
camera at it and start playing right away – nothing to install. The code is fixed
markup (no generator ships with the app); node tools/generate-qr.mjs regenerates
it should the address ever change.
An entry submitted without a name stores no name; “Anonymous” is only the display, and every reader sees it in their own language.
These settings, the last name used and the local best times are kept in
localStorage (see Leaderboard below). A game in progress is deliberately
not stored – reloading the page starts a fresh puzzle.
After solving, the win screen shows a result and asks whether you want to be listed. The result is an “effective time”: the raw solving time plus a penalty per hint used (+3 s per row: 15 s on a 5×5, 24 s on an 8×8, 42 s on a 14×14) and per check (+1 s per row, so +8 s on an 8×8). Both buttons show their price before you press them, a small “+24 s” (or whatever the board's price is) rises off the button whenever an aid is actually charged, and the clock runs on the effective time, so the number you watch is the number you get. Asking for the same hint again is free and costs no time. Lower is better. Mistakes are counted and shown, but cost nothing – a wrong queen already costs time, because you have to spot it and take it back, and on a phone a mis-tap is a thumb's width away; charging for it a second time punished the input surface rather than the reasoning. Every combination of board size and difficulty has its own ranking; 🏆 lets you browse all of them.
- Mine: Best times are always stored on the device (up to 50 per ranking), with no server at all. The last name entered is remembered so it is pre-filled after every round. The tab used to be called Local; "Mine" says the same thing in the players' language rather than the machine's – and makes clear that every one of your games is here, including the ones the global list doesn't show.
- Global (optional): If an online ranking is set up, a Submit button and a Global tab appear, likewise with up to 50 entries. Without that setup everything simply stays local – online is never a requirement.
Both lists show roughly six to eight rows and scroll beyond that so the win screen doesn't grow; your own fresh row is scrolled into view automatically.
On an exact tie the older entry keeps the better place: matching a time isn't enough, you have to beat it. That holds locally and globally, and in the preview exactly as after saving – so the list no longer re-sorts itself the moment you submit.
Every row also shows how old the entry is (“3 days ago”; the exact date is in the tooltip), and anything from the last week is highlighted in green. Without that a leaderboard reads as frozen – this way you can see at a glance whether anyone is playing right now.
When the global ranking is set up and a bucket is busy enough, a third tab “90 days” appears: the same list, restricted to entries from that window. It shows up only where it says something – with fewer than five entries inside the window there would be nothing to compare, and if every entry falls inside it anyway, it would just be a copy of the global list. Deliberately a rolling window rather than “this month”: a calendar month is empty on the 1st and full on the 28th, so the same result would read completely differently depending on the date.
Because a list ends somewhere, a first place beyond it says nothing on its own. So the game additionally remembers the results of all games per ranking (the score and when it was solved, no names) and shows right on the win screen how the fresh game compares – e.g. “Better than 88 % of your 26 games”, the gap to your own best time, or “🏆 New best time!”. This feedback is there instantly and needs neither a name nor the internet. With few games (under five) the plain placement is shown instead – a percentage out of two rounds would be noise.
Because the game history now knows the date of every game, a third line is added where it helps, comparing the same solve against your current form: “Last 30 days: better than 92 % of 12 games” or “🔥 Your best time in 30 days”. It, too, only shows up when it adds something – if all your games fall inside those 30 days it says nothing the line above doesn't already say, and is left out.
After submitting to the global ranking, the status line adds the same comparison for the whole field – counted in players, not entries: “Rank 3 of 12 players”. A ranking of 83 entries belonging to three names does not describe a field of 83 people, and “rank 28 of 83” reads like a closed shop to a newcomer. If this run was slower than your own best, it says so (“Not a personal best – your best still holds rank 1 of 3 players”) instead of crediting the run with a placement it didn't earn.
The global list itself is capped per player, and the cap adapts to how many people play: the places are shared out evenly, so each player shows at most places ÷ players of their best times (rounded up). With three players on 50 places that is 17 each, with ten it is 5, and from 50 players on it is one row per player. Nothing is deleted – the rank above still counts every game, and your own Mine list keeps them all – and when the cap holds rows back, a line at the foot of the list says how many.
In the Global tab your own freshly submitted row is outlined in green – exactly like in the Mine tab. Until you submit, nothing is highlighted there, because your result genuinely isn't on that board yet (the status line points this out). In the 🏆 leaderboard, where there is no submitting, your best row is marked via the remembered name instead – without a name nothing is marked, since outlining a stranger's row as "you" would simply be a lie.
Anyone who played before this feature existed doesn't lose the comparison: the existing entries of the local list are adopted as game history on first start – including their date, so the 30-day window works right away too. Those are only the best games, though – so as long as few new ones have been added, the percentage comes out rather strict.
GitHub Pages only serves static files, but the game can still reach an online
ranking via fetch(). A free Supabase project is enough
as a backend – no server of your own needed.
- Create a Supabase project.
- Run
docs/leaderboard-setup.sqlin the project's SQL editor. That creates the table and the validated functionssubmit_score/top_scores/score_counts(the server-side plausibility checks = the abuse protection). If you have been running the ranking for a while, simply run the file again: without it the entry age and the 90-day tab stay off – nothing else changes, since both fail soft instead of breaking anything. - Enter the project URL and the public anon key in
js/leaderboard.js. Both values may live in the browser; theservice_rolekey must never go there.
An honest note: since the browser reports its own time, no such ranking is tamper-proof. The server checks (reject impossible times, bound the values, best-effort rate limit) only keep out crude nonsense – enough for a hobby game, no tournament claim. Instead of a raw IP, only a salted daily hash is stored for the rate limit.
That is exactly why the lower time bound is deliberately very loose: it used
to be “board size in seconds” and rejected genuinely fast runs (a 6×6 in 5 s is
quite doable in quick mode). Since anyone tampering could simply send a
plausible-looking time anyway, that check only cost functionality. Now only the
impossible (0 seconds) is rejected. If you already set the ranking up, apply the
MIGRATION block at the end of docs/leaderboard-setup.sql (or just run the
whole file again – it is repeatable and leaves existing data untouched).
If the server refuses an entry, the win screen now says why (e.g. “Rejected globally: time judged impossible”) instead of “unreachable” – and offers no pointless second attempt. It is saved locally in any case, before anything is sent at all.
“New game” starts instantly: puzzles aren't computed live but drawn from
precomputed pools in levels/ – one JSON file per combination of board size and
difficulty, 50 puzzles each. That is 24 pools holding 1200 puzzles in total
(board sizes 12 to 14 only have a hard pool, see above). So that nothing becomes
familiar, every drawn puzzle is randomly rotated or mirrored (8 symmetries)
and gets random colours as before – 50 stored shapes thus turn into hundreds of
distinguishable boards. Within a session no shape repeats until every one has had
its turn (in memory only, nothing is persisted).
The colour regions are built in three different styles, and every pool holds an even share of each:
- organic – amoeba-like regions with ragged borders.
- blocky – regions grow in straight strips, giving long straight borders, rectangular shapes and one large background colour.
- strips – every colour but one is a straight, one-cell-wide segment, and the one that isn't is a single background covering most of the board.
Because every puzzle in a pool comes up once before anything repeats, the looks alternate evenly while playing. The style changes nothing about the difficulty – that depends solely on which thinking techniques a board demands. Two levels are exceptions, both for the same kind of reason: at Easy the blocky difference is barely noticeable (that level needs the small “gifted” regions that would be what makes the blocky look in the first place), and Easy has no strips puzzles at all, because the size floor that creates that look is exactly what removes easy's forced opening. Above size 12, strips is the only style – it is the one that generates a board of that size fast enough to exist at all.
If loading a pool fails (e.g. files changed offline), the game generates the puzzle live in the background as it used to – so there is always a board, then with a randomly chosen style.
The pools are built with node tools/generate-levels.mjs and checked with
node tools/verify-levels.mjs (uniqueness, difficulty, symmetries, solvability
purely via hints). After changes to the generator/solver logic, both have to run
again.
The repo contains a GitHub Actions workflow (.github/workflows/deploy.yml) that
deploys the site automatically on every push. One-time setup:
- In the repository Settings → Pages, under Build and deployment → Source, pick “GitHub Actions”.
- Push to one of the branches configured in the workflow (
main,masteror the development branch). - Once the action has run, the site is reachable at the Pages URL it reports.
Since this is a static site, you can alternatively pick “Deploy from a branch” in
the Pages settings and publish the repo root (/).
index.html – Page skeleton
css/styles.css – Layout & responsive, mobile design
js/solver.js – Rules, solution counting, logic solver (difficulty rating)
js/generator.js – Puzzle generation with a guaranteed-unique solution (fallback & pool builds)
js/levels.js – Loads the precomputed pools, rotates/mirrors at random
js/game.js – Game state, quick mode, conflict & win detection
js/hint.js – The next logical deduction as an explainable hint
js/highscores.js – Score model, local best times & game history for the comparison (localStorage)
js/leaderboard.js – Optional global online ranking (Supabase, falls back to local silently)
js/settings.js – Preferences & last name (localStorage)
js/i18n.js – Translation layer: t(), language resolution (no DOM)
js/i18n/en.js, de.js, – Language packs (one key set, identical per language)
fr.js, es.js, pt.js, ru.js
js/audio.js – Minimalist sound effects (Web Audio API, no asset files)
js/voice.js – Voice control: pure command parser + Web Speech wrapper (no DOM)
js/main.js – DOM wiring, rendering, controls
levels/ – Precomputed puzzle pools (JSON, per size × difficulty)
docs/leaderboard-setup.sql – SQL for setting up the optional Supabase ranking
tools/generate-levels.mjs – Rebuilds the pools
tools/verify-levels.mjs – Checks all pools (uniqueness, level, symmetries, hints)
tools/build-artifact.mjs – Bundles the app into one file (mobile test as an Artifact)
tools/generate-qr.mjs – Regenerates the share QR code embedded in index.html
tests/logic/verify-i18n.mjs – Checks the language packs for matching keys (runs in CI)
The interface is available in English, German, French, Spanish,
Portuguese and Russian. All text lives in js/i18n/<language>.js; every
file carries exactly the same set of keys. Another language is a copy of
js/i18n/en.js plus an entry in I18N_PACKS / I18N_LANGUAGES in js/i18n.js
and in the module list in tools/build-artifact.mjs.
A pack is not a word-for-word translation: composed sentences are functions,
so each language writes its own sentence rather than filling slots someone else
laid out, and its grammar rules live in the pack that needs them — nothing is
shared, and nothing in js/i18n.js knows a language exists. That is what lets
Russian carry three plural forms (1 подсказка · 2 подсказки · 5 подсказок) and
decline its unit nouns through four cases, while French treats 0 as singular and
Portuguese does not, all without touching a single caller. Locale data —
percent spacing, dates, relative time, plural categories — is delegated to
Intl rather than typed by hand, so a new language needs no new dependency.
Watch the layout too: the labels are white-space: nowrap, and the three-tab
score row is the tightest line in the app — it is what forced Russian's
leaderboard tabs to be shortened. node tests/browser/i18n-layout.mjs measures
exactly that.
node tests/logic/verify-i18n.mjs checks that no language loses a key or a
placeholder – it runs in CI too, so a slip shows up at build time rather than on
screen. Deliberately not translated are the voice control (a German speech
grammar, see above) and the debug log (developer output; one fixed language
keeps bug reports readable).
Because of ES modules the page has to be served over HTTP (not via file://):
python3 -m http.server 8000
# then open http://localhost:8000Bug reports, ideas and pull requests are welcome — see CONTRIBUTING.md for how to get set up and what to run before opening a PR. The in-game settings (⚙ → the ⓘ button) also link to the repo.