The problem fit in sixteen combinations
Numberlink shipped with a concrete promise on its card: four sizes, four difficulties and a "certified unique solution". The uniqueness promise was real — the engine checked it — but the variety promise was not. Behind the board sat a generator of nearly straight lines: a single topology per size-and-difficulty combination. Sixteen combinations, sixteen board shapes, and the feeling of replaying the same thing over and over.
For a path puzzle that is especially damaging. Numberlink asks you to join pairs of numbers with orthogonal paths that fill the whole grid without crossing or overlapping. If the solutions are straight or nearly straight, the interesting part of the game — deciding where each path goes — disappears. The player does not solve; they trace. A connection puzzle lives or dies by the shape of its paths, and sixteen topologies are not enough for an experience that invites you back.
Issue #402, flagged as P1, put it bluntly: "evaluate and replace the trivial generation with a deterministic external engine". The important word in that phrasing is evaluate. It did not ask us to bolt on another generator and cross our fingers. It asked us to understand first what was wrong, measure what "better" meant, and only then decide.
What "generating" should mean
We had already written about the difference between generating and solving and about why one solver does not fit every game. Numberlink put the same problem in front of us again, with a new wrinkle. It was not enough for a generator to produce a valid board. We had to be able to tell apart three things that look alike and are not.
The first is validity: the board can be completed covering every cell. The second is uniqueness: there is exactly one way to do it. The third is variety: two different boards are not the same board rotated, reflected or relabelled. The old generator satisfied the first and, with the solver's help, the second. It failed badly on the third.
Measuring variety without being fooled required a specific detail. A Numberlink board can be rotated four times and reflected four more: eight transformations that produce the same puzzle with different coordinates. If the audit counted every transformation as a different puzzle, any bank would look more varied than it is. That is why the canonization ignores rotations, reflections and label order, and reduces every board to a single topological key. Two boards that produce the same key are, as far as the game is concerned, the same board.
Variety cannot be taken for granted. Two puzzles can have different coordinates and be, topologically, the same idea. That is why the first technical task was not writing a better generator, but building a way to measure diversity that would not be fooled by symmetries or by the order of labels.
The first decision: measure before replacing
PR #403 was a research phase, not an implementation one. It added a reusable auditor, scripts/numberlink-quality-audit.mjs, with two responsibilities. On one side it canonizes a puzzle's topology: it does not matter which letter or number labels each pair, and it does not matter how the board is rotated or reflected; two boards that are the same idea produce the same key. On the other side it measures geometry: how many paths are straight, how many turns there are, and how many pairs have their endpoints too close together.
That tool had an important advantage: it could run against the current generator and against any future candidate using the same yardstick. The command node scripts/numberlink-quality-audit.mjs --samples 32 produced comparable numbers instead of impressions, and it also accepted a --certify option to check the uniqueness of a specific puzzle. The same tool served to audit the current generator and, later, to compare external candidates against the same measuring stick.
The PR was explicit about its limits: Node latency is not mobile performance, and counting turns does not by itself measure difficulty for a human. Separating evidence from decision prevented a pretty number from turning into a rushed conclusion.
That separation set the tone for everything that followed. We were not going to replace the bank in a single PR, because the replacement needed an acceptance criterion first and, after that, a candidate that met it.
The external candidate: Tsunagi 1.6.3
With the auditor ready, the next step was to find a source of real puzzles. That is where a project that was not ours entered the picture: Tsunagi, by John Morris, published under the MIT license. Tsunagi is a Numberlink generator and solver with a collection of classic levels and a solver that proves uniqueness in its own CI. It was exactly what we needed: curated puzzles, with a traceable answer and a license that allowed reuse.
The decision was not "import Tsunagi and done". We froze the source at a specific commit, db4c42fcff2a7f38f9b1424826f8680b3f57205a, so the provenance would be reproducible and auditable. We did not copy its graphical interface; we only took the level data and, later, the generator. And we applied our own filter: only levels of 8×8, 10×10, 12×12 and 15×15, with no walls, bridges, waypoints or special rules, with a traceable answer, full cover and no contiguous endpoints.
Filtering matters because Tsunagi includes levels with mechanics Blupoli does not have. Taking the whole corpus would have introduced puzzles our engine could not represent, or that would break the rules we show the player. The license was preserved in static/js/engines/LICENSE.tsunagi-MIT, and the resulting bank documents its origin on every entry.
The bank of 572 puzzles
PR #408 replaced the trivial bank with 572 classic boards: 224 of 8×8, 116 of 10×10, 116 of 12×12 and 116 of 15×15. The paths do not arrive as ready-to-paint coordinates; they arrive as a layout of endpoints and a solution encoded by colors. Blupoli rebuilds each path from that solution and validates that, in the end, every cell is covered exactly once.
Reconstruction is a real step of code, not a trivial conversion. The provider's solution arrives encoded as a string of colors, one letter per cell. Blupoli walks that string and rebuilds each path by following the cells that share the same color: it starts at one endpoint and advances through orthogonally adjacent cells until it reaches the other endpoint. The process validates on the fly that each pair has exactly two endpoints and that the path neither crosses itself nor leaves the board. At the end, an independent check confirms no cell is left uncovered and none is covered twice.
That step is more subtle than it looks. We do not trust the file to be well-formed; we check it. If a pair does not have exactly two endpoints, or if a path fails to cover the whole board, the puzzle is rejected. Uniqueness is further verified with Blupoli's own independent solver on a sample of the bank, so we do not rely solely on the guarantee Tsunagi provides in its own CI.
The change brought a compatibility decision with it. The bank version number moved to 2, and puzzle identifiers now carry source, index, symmetry and seed. A game saved against the old geometry can no longer be restored against the new one, because it would be a different board. Incompatible snapshots are discarded rather than forced to fit. It is an uncomfortable rule — a player loses an in-progress old game — but it is the only honest one: restoring moves onto a different puzzle is worse.
The daily challenge changed too. The seeds daily:YYYY-MM-DD choose deterministically in UTC, and the parameter ?daily=1&date=YYYY-MM-DD opens the same daily puzzle across devices. Each level walks its collection without repeating topologies within a cycle, so two players opening the challenge on the same day see the same board. The v6 straight-line generator, the gates and the obsolete npm commands were removed.
The test of the bank matters as much as the bank itself. The tests check that the 572 topologies contain no duplicate symmetries, that each board's geometry is correct, that the seeds are deterministic and that the daily cycle walks the collection without repeating itself. An independent sample also verifies uniqueness with Blupoli's solver, so the provider's guarantee is not the only barrier. If any of those checks fails, the puzzle does not ship.
A finite bank is not a generator
The bank of 572 solved the immediate problem — real variety, reproducibility, uniqueness — but it was still finite. A large bank is an improvement, not a generator. For a game that aims to offer endless new rounds, staying dependent on a frozen collection meant accepting that, sooner or later, the puzzles repeat.
This is where the second important decision appears. We could have stopped at the bank, which was already an enormous leap from the sixteen straight boards. But issue #402 explicitly asked us to evaluate dynamic generation. PR #408 said it in writing: the bank is a safe fallback and an immediate improvement, not a simulation of infinity. Dynamic generation was evaluated separately.
Generating dynamically has a cost a bank does not: you have to prove, every single time, that the puzzle is valid and unique, and do it within a time budget the player is willing to wait. That cost cannot be paid by blocking the UI thread.
The dynamic generator in a Web Worker
PR #415 brought in dynamic generation using the Tsunagi 1.6.3 generator, without reusing its interface. The centerpiece is a Web Worker that imports only the provider's solver. While the worker searches and proves uniqueness, the interface stays interactive and the game keeps working locally and offline.
The worker does not call a remote service. The Tsunagi ESM modules are copied locally into the build, with no CDN, an exact-pinned dependency version and the MIT license inherited from the earlier corpus. The PRNG is derived from the seed, the size, the difficulty and the version, so the same input produces the same puzzle. Determinism is not a cosmetic detail: every seed is run through an FNV-1a hash of its full string, and the result decides which board is served. The same text produces the same hash and therefore the same board on any device at any time. That is what powers the daily challenge: two players opening the same date see exactly the same puzzle without any server having to hand it out. The determinism lives in the seed, not in a clock or in global state.
The module numberlink-dynamic.js adapts the provider's candidate and repairedCandidate functions, converts the tracings and validates cover and geometric metrics. The most important detail is in the provider's contract: Tsunagi's functions return a candidate only when their solver has proven exactly one answer. A timeout or a null value is not proof. That fits exactly what we wanted: you cannot confuse "the solver found no other solution" with "the solver proved no other solution exists". The dynamic puzzle is stored whole in the snapshot so it can be restored identically, even if another device timed out on the attempt. Separating what each context imports was another hygiene decision: numberlink-dynamic.js touches neither the DOM nor package imports, and receives the generator from the worker through a clean interface. The worker is the only thing that imports the Tsunagi solver. That way Node tests can pass the same pinned implementation without launching a browser, and the UI does not drag along generation dependencies it does not need.
Uniqueness as certification, not hope
Validation of a dynamic puzzle is strict and legible. verifyDynamicPuzzle checks that the puzzle declares the correct generation, bank version and generator version; that its validation is exact-unique; that the certification marks uniqueness; that size, difficulty and seed match what was asked; and that the paths are valid, orthogonal and cover the grid completely. A puzzle that fails any of those checks is not delivered.
Difficulty stopped being an arbitrary label and became a budget with a shape. Each difficulty defines a maximum path length, a search budget in milliseconds, a number of attempts and geometric thresholds: maxStraight (maximum straight paths) and minTurns (minimum turns). An "easy" puzzle allows more straight paths and fewer turns; an "expert" puzzle demands longer, windier tracings. Difficulty describes the shape of the puzzle, not an empty promise. The concrete numbers make that logic visible: "easy" allows paths of up to 5, 5, 6 and 14 cells across the four sizes, a 2,000-millisecond budget, 30 attempts, at most 58 % straight paths and at least 0.45 turns per path. "Expert" raises the maximum length to 12, 15, 18 and 24 cells, grants 10,000 milliseconds and 48 attempts, and demands at most 38 % straight paths and at least 1.25 turns per path. These are not decorative figures: they are the contract that separates an easy puzzle from an expert one, and they can be audited just like uniqueness.
That calibration is still marked as approximate. The provider's ordered classification is the starting point, but real observation of players has not been completed yet. We wrote that down rather than sell a certainty we do not have.
The time budget and the fallback
Generating in real time is a bet against the clock. The worker bounds the attempts and the search budget, with a timeout that leaves headroom but does not let the phone wait forever. If the worker cannot produce a certified puzzle within the limit, the round falls back to the certified v2 bank. The player is never left without a puzzle, and quality never degrades through haste: either there is a certified dynamic puzzle, or there is a certified puzzle from the bank.
The daily challenge deliberately stays on the v2 bank. It needs every player to see the same board on the same day, and the bank's seed determinism guarantees that without depending on the luck of a dynamic search. Separating "free play" from the "daily challenge" prevents a generation failure from breaking fairness between users or the continuity of historical dates.
What we deliberately left out
There are things we chose not to do in this round. We did not reuse Tsunagi's graphical interface: only its generator and solver, which is where the value was. We did not yet build the general daily-challenge UI and progress tracking for Blupoli; the ?daily=1 parameter works, but it does not replace that screen. And we did not claim dynamic generation is equivalent to the bank across all sizes until the comparison is complete.
Difficulty calibration with real players is still pending. It is the kind of debt worth naming: the system is deterministic and certified, but "easy" and "expert" are still approximations inherited from the provider, not our own measurements. Meanwhile, the bank remains the safety net for any case where dynamic generation does not arrive in time.
What changed for the player
From the outside, the change fits in one sentence: Numberlink no longer replays the same sixteen shapes. The player finds paths with real turns, distinct topologies and the certainty that every puzzle has a single solution, whether it comes from the bank or is generated on the spot. A game is saved so it restores identically, and the daily puzzle stays the same for everyone.
For maintenance, the result is just as concrete. The v2 bank is auditable and traceable to a source commit. The dynamic generator lives in a worker that does not block the UI. Uniqueness validation is an explicit check, not blind trust in the provider. And there is a tool — the diversity auditor — that lets us re-measure any future change with the same criterion.
The result can be tried directly in Numberlink. For the context of how Blupoli separates engines and games, the devlogs on generating is not solving and one solver does not fit them all explain the same idea from other layers.
The lesson: measure before you migrate
This journey leaves a lesson that goes beyond Numberlink. When a system produces poor results, the temptation is to replace it with another system and check afterwards whether it was a good idea. Here we did the opposite: first we defined what "better" meant with a reproducible metric, then we found a candidate that met it, and only then did we migrate, always leaving a certified fallback in place.
The second lesson is about provenance. Reusing an MIT-licensed external project is not copying and forgetting. It is freezing a version, preserving attribution, filtering out what does not fit the product, and verifying with our own solver that the provider's guarantee holds. Trust is delegated; responsibility is not.
And the third is about time. Generating in the browser is a race against a budget the player cannot see but can feel. The solution was not to make the generator faster at any cost, but to move it to a worker and accept a fallback when the search does not arrive in time. Quality is protected with an escape route, not with the hope that everything goes well.
Numberlink went from being a puzzle with four disguised variants to a game with real variety and certified generation. The visible part is what the player sees: paths that finally cross and wind. The invisible part is what remains for us: a criterion for measuring, a bank for falling back, and a worker that silently proves that every puzzle has a single answer.