I wanted a daily path puzzle with one twist that changes how you think, not just a bigger grid. The rule I landed on: you draw one line, and the game draws its twin rotated 180 degrees. Your line and the twin may not overlap, and together they must fill every square.
You can play it free here: Twinline on itch.io. This post covers how the puzzles are generated and checked, plus two bugs that cost me most of the build time.
The rule as data
On an n x n board with n even, cell c at (row, col) has the twin (n-1-row, n-1-col). No cell is its own twin when n is even, so the board splits cleanly into n*n/2 twin pairs. A solution is a path of exactly n*n/2 cells that uses one cell from every pair.
const twin = c => (n - 1 - ((c / n) | 0)) * n + (n - 1 - c % n);
When the player takes a cell, its twin is blocked too. One used array covers both:
used[x] = used[twin(x)] = 1;
Step 1: a random solution path
I grow a path with depth-first search. At each step it tries the neighbours with the fewest free exits first (Warnsdorff's rule), plus a little random noise so boards don't all look the same:
const opts = nb[c].filter(x => !used[x]).map(x => [x, free(x) + rnd() * 2.5]);
opts.sort((a, b) => a[1] - b[1]);
Plain DFS on 8x8 sometimes wandered for seconds. The pruning that fixed it is a connectivity check: from the head of the line, flood fill through the free cells and count how many twin pairs are still reachable. If that is fewer than the pairs still needed, this branch can never finish.
function connected(c, used, nb, tw, left, block) {
const seen = new Uint8Array(used.length), st = [c];
let pairs = 0;
seen[c] = 1;
while (st.length) {
const u = st.pop();
for (const v of nb[u]) {
if (used[v] || seen[v] || (block && block[v])) continue;
if (!seen[tw[v]]) pairs++; // count a pair once, whichever half we reach first
seen[v] = 1; st.push(v);
}
}
return pairs === left;
}
I also reject solutions that stay almost entirely in one half of the board. They are technically valid but play like half a puzzle.
Step 2: clues until the answer is unique
Numbers are placed along the solution path: the first and last cell, then roughly every fourth cell. Then a solver counts solutions up to 2. If it finds a second solution, I look at where that solution first disagrees with mine and put a number there. That kills the other solution with the smallest possible change. Repeat until exactly one solution is left.
Then every clue gets removed in turn, in random order, and kept out if the puzzle stays unique. Easy and Medium get a few clues back afterwards so they feel friendlier.
Bug 1: the solver ignored what the numbers imply
The first solver was correct but slow. On some 8x8 seeds it hit the node cap on every check, so the generator added numbers forever, because "gave up" counted as "not unique".
The missing deduction: a numbered cell is always on your line, so its twin is never on it. Without that rule, the solver spent ages exploring paths through cells that could never be used. Blocking every clue's twin before the search starts made checks about 40 times faster and the hang went away:
const block = new Uint8Array(total);
clues.forEach((k, c) => { if (k) block[tw[c]] = 1; });
That is now also a hint in the game itself: the twin of each number is shown as a faint ghost number, so players learn this deduction by looking at it.
Bug 2: my separator was a valid cell
To keep phones from running the solver at all, I generate 300 puzzles per level offline and ship them as one compact string each: one character per cell, String.fromCharCode(48 + cell), solution and clue positions joined by |.
That worked on 6x6 and 8x8. On 10x10 the generator crashed while checking the bank. 48 + 76 is 124, which is |. Any puzzle whose path used cell 76 was split in the wrong place. The fix was to use a space (char 32 sits below the encoding range) and to re-split the existing bank by length, since the solution length n*n/2 is known.
The lesson I took from it: when you pack numbers into characters, choose the separator from outside the range you encode, not from what looks unused in a small test.
Result
- 900 puzzles (Easy 6x6, Medium 8x8, Hard 10x10), all checked offline for exactly one solution
- The page loads one 45 KB data file; no solver runs on the phone
- One HTML file plus that data file, canvas drawing, pointer events, works with touch and mouse
The daily puzzle is the same for everyone, so you can compare times. If you try it, I'd like to hear whether the Hard boards feel fair.
If you enjoy this kind of write-up, I post more builds on Patreon.
Top comments (0)