El problema cabía en dieciséis combinaciones
Numberlink se publicó con una promesa concreta en su ficha: cuatro tamaños, cuatro dificultades y «solución única certificada». La promesa de unicidad era real — el motor la comprobaba —, pero la de variedad no. Detrás del tablero había un generador de líneas casi rectas: una única topología por cada combinación de tamaño y dificultad. Dieciséis combinaciones, dieciséis formas de tablero, y la sensación de rejugar siempre lo mismo.
Para un puzzle de caminos, eso es especialmente grave. Numberlink pide unir parejas de números con caminos ortogonales que ocupen toda la cuadrícula sin cruzarse ni solaparse. Si las soluciones son rectas o casi rectas, desaparece la parte interesante del juego: decidir por dónde pasa cada camino. El jugador no resuelve; traza. Un puzzle de conexiones vive o muere por la forma de sus caminos, y dieciséis topologías no dan para una experiencia que invite a volver.
La Issue #402, marcada como P1, lo formuló sin rodeos: «evaluar y sustituir la generación trivial por un motor externo determinista». Lo importante del enunciado es la palabra evaluar. No pedía pegar otro generador y cruzar los dedos. Pedía entender primero qué estaba mal, medir qué significaba «mejor» y solo entonces decidir.
Lo que «generar» debería significar
Ya habíamos escrito sobre la diferencia entre generar y resolver y sobre por qué un único solver no sirve para todos los juegos. Numberlink volvió a poner el mismo problema delante, con un matiz nuevo. No bastaba con que un generador produjese un tablero válido. Teníamos que poder distinguir tres cosas que se parecen y no son lo mismo.
La primera es validez: el tablero se puede completar cubriendo todas las casillas. La segunda es unicidad: hay exactamente una forma de hacerlo. La tercera es variedad: dos tableros distintos no son el mismo tablero girado, reflejado o reetiquetado. El generador antiguo cumplía la primera y, con ayuda del solver, la segunda. Fallaba estrepitosamente en la tercera.
Medir la variedad sin dejarse engañar exigió un detalle concreto. Un tablero de Numberlink puede rotarse cuatro veces y reflejarse otras cuatro: ocho transformaciones que producen el mismo puzzle con coordenadas distintas. Si el auditor contara cada transformación como un puzzle diferente, cualquier banco parecería más variado de lo que es. Por eso la canonalización ignora rotaciones, reflejos y el orden de las etiquetas, y reduce cada tablero a una única clave topológica. Dos tableros que producen la misma clave son, a efectos del juego, el mismo tablero.
La variedad no se puede dar por supuesta. Dos puzzles pueden tener coordenadas distintas y ser, topológicamente, la misma idea. Por eso la primera tarea técnica no fue escribir un generador mejor, sino construir una forma de medir la diversidad que no se dejase engañar por simetrías ni por el orden de las etiquetas.
La primera decisión: medir antes de sustituir
La PR #403 fue una fase de investigación, no de implementación. Añadió un auditor reutilizable, scripts/numberlink-quality-audit.mjs, con dos responsabilidades. Por un lado, canonaliza la topología de un puzzle: da igual qué letra o número identifique cada pareja, y da igual la rotación o el reflejo del tablero; si dos tableros son la misma idea, producen la misma clave. Por otro, mide geometría: cuántos caminos son rectos, cuántos giros hay y cuántas parejas tienen los extremos demasiado cerca.
Esa herramienta tenía una ventaja importante: se podía ejecutar contra el generador actual y contra cualquier candidato futuro con el mismo criterio. El comando node scripts/numberlink-quality-audit.mjs --samples 32 daba números comparables en lugar de impresiones, y admitía además una opción --certify para comprobar la unicidad de un puzzle concreto. La misma herramienta servía para auditar el generador actual y, después, para comparar candidatos externos sobre la misma vara de medir. La PR fue explícita sobre sus límites: la latencia en Node no es rendimiento móvil, y contar giros no mide por sí solo la dificultad para un humano. Separar la evidencia de la decisión evitó que un número bonito se convirtiese en una conclusión apresurada.
Esa separación marcó el tono de todo el trabajo posterior. No íbamos a sustituir el banco en una sola PR porque la sustitución necesitaba primero un criterio de aceptación y, después, un candidato que lo cumpliese.
El candidato externo: Tsunagi 1.6.3
Con el auditor listo, el siguiente paso era encontrar una fuente de puzzles reales. Aquí entró un proyecto que no era nuestro: Tsunagi, de John Morris, publicado bajo licencia MIT. Tsunagi es un generador y solver de Numberlink con una colección de niveles clásicos y un solver que demuestra la unicidad en su propio CI. Era exactamente lo que necesitábamos: puzzles curados, con respuesta trazable y con una licencia que permitía reutilizarlos.
La decisión no fue «importar Tsunagi y ya está». Congelamos el código fuente en un commit concreto, db4c42fcff2a7f38f9b1424826f8680b3f57205a, para que la procedencia fuese reproducible y auditable. No copiamos su interfaz gráfica; solo tomamos los datos de nivel y, más adelante, el generador. Y aplicamos un filtro propio: solo niveles de 8×8, 10×10, 12×12 y 15×15, sin paredes, puentes, waypoints ni reglas especiales, con respuesta trazable, cobertura completa y sin extremos contiguos.
Filtrar importa porque Tsunagi incluye niveles con mecánicas que Blupoli no tiene. Tomar el corpus completo habría introducido puzzles que nuestro motor no podría representar o que romperían las reglas que mostramos al jugador. La licencia se preservó en static/js/engines/LICENSE.tsunagi-MIT, y el banco resultante documenta su origen en cada elemento.
El banco de 572 puzzles
La PR #408 sustituyó el banco trivial por 572 tableros clásicos: 224 de 8×8, 116 de 10×10, 116 de 12×12 y 116 de 15×15. Los caminos no vienen dibujados como coordenadas listas para pintar; vienen como una disposición de extremos y una solución codificada por colores. Blupoli reconstruye cada camino a partir de esa solución y valida que, al final, todas las casillas quedan cubiertas exactamente una vez.
La reconstrucción es un paso real de código, no una conversión trivial. La solución del proveedor llega codificada como una cadena de colores, una letra por casilla. Blupoli recorre esa cadena y reconstruye cada camino siguiendo las casillas que comparten el mismo color: empieza por un extremo y avanza por casillas ortogonalmente adyacentes hasta llegar al otro extremo. El proceso valida sobre la marcha que cada pareja tenga exactamente dos extremos y que el camino no se cruce ni se salga del tablero. Al final, una comprobación independiente confirma que no queda ninguna casilla sin cubrir y ninguna cubierta dos veces.
Ese paso de reconstrucción es más sutil de lo que parece. No confiamos en que el archivo esté bien formado: lo comprobamos. Si una pareja no tiene exactamente dos extremos, o si un camino no llega a cubrir todo el tablero, el puzzle se rechaza. La unicidad se verifica además con un solver independiente de Blupoli sobre una muestra del banco, de modo que no dependemos solo de la garantía que Tsunagi ofrece en su propio CI.
El cambio trajo consigo una decisión de compatibilidad. El número de versión del banco pasó a 2 y los identificadores de puzzle incorporaron fuente, índice, simetría y semilla. Una partida guardada sobre la geometría antigua ya no puede restaurarse sobre la nueva, porque sería jugar otro tablero. Los snapshots incompatibles se descartan en lugar de intentar encajarlos. Es una regla incómoda — el jugador pierde una partida antigua en curso — pero es la única honesta: restaurar movimientos sobre un puzzle distinto es peor.
El juego del día también cambió. Las semillas daily:YYYY-MM-DD eligen de forma determinista en UTC, y el parámetro ?daily=1&date=YYYY-MM-DD permite abrir el mismo puzzle diario entre dispositivos. Cada nivel recorre su colección sin repetir topologías dentro de un ciclo, de modo que dos jugadores que abren el reto el mismo día ven el mismo tablero. El generador de líneas rectas v6, los gates y los comandos npm obsoletos se eliminaron.
La prueba del banco es tan importante como el banco mismo. Los tests comprueban que las 572 topologías no contienen simetrías duplicadas, que la geometría de cada tablero es correcta, que las semillas son deterministas y que el ciclo diario recorre la colección sin repetirse. Un muestreo independiente verifica además la unicidad con el solver de Blupoli, de modo que la garantía del proveedor no sea la única barrera. Si una de esas comprobaciones falla, el puzzle no se publica.
Un banco finito no es un generador
El banco de 572 resolvía el problema inmediato — variedad real, reproducibilidad, unicidad — pero seguía siendo finito. Un banco grande es una mejora, no un generador. Para un juego que aspira a ofrecer partidas nuevas sin fin, seguir dependiendo de una colección congelada significaba aceptar que, tarde o temprano, los puzzles se repiten.
Aquí aparece la segunda decisión importante. Podíamos habernos quedado en el banco, que ya era un salto enorme respecto a los dieciséis tableros rectos. Pero la Issue #402 pedía explícitamente evaluar la generación dinámica. La PR #408 lo dejó escrito con claridad: el banco es un fallback seguro y una mejora inmediata, no simula el infinito. La generación dinámica se evaluó por separado.
Generar dinámicamente tiene un coste que un banco no tiene: hay que demostrar cada vez que el puzzle es válido y único, y hacerlo dentro de un presupuesto de tiempo que el jugador esté dispuesto a esperar. Ese coste no puede pagarse bloqueando el hilo de la interfaz.
El generador dinámico en un Web Worker
La PR #415 incorporó la generación dinámica usando el generador de Tsunagi 1.6.3, sin reutilizar su interfaz. La pieza central es un Web Worker que importa únicamente el solver del proveedor. Mientras el worker busca y demuestra la unicidad, la interfaz sigue interactiva y el juego sigue funcionando en local y sin conexión.
El worker no llama a un servicio remoto. Los módulos ESM de Tsunagi se copian localmente en el build, sin CDN, con una dependencia de versión exacta y la licencia MIT heredada del corpus anterior. El PRNG se deriva de la semilla, el tamaño, la dificultad y la versión, de modo que la misma entrada produce el mismo puzzle. La determinación no es un detalle cosmético: cada semilla pasa por un hash FNV-1a de su cadena completa, y el resultado decide qué tablero se sirve. El mismo texto produce el mismo hash y, por tanto, el mismo tablero en cualquier dispositivo y en cualquier momento. Eso es lo que permite el desafío diario: dos jugadores que abren la misma fecha ven exactamente el mismo puzzle sin que ningún servidor tenga que repartirlo. La determinación vive en la semilla, no en un reloj ni en un estado global.
El módulonumberlink-dynamic.js adapta las funciones candidate y repairedCandidate del proveedor, convierte los trazados y valida cobertura y métricas geométricas.
El detalle más importante está en el contrato del proveedor: las funciones de Tsunagi devuelven un candidato solo cuando su solver ha demostrado que hay exactamente una respuesta. Un timeout o un valor nulo no es una prueba. Eso encaja con lo que queríamos: no se puede confundir «el solver no encontró otra solución» con «el solver demostró que no existe otra solución». El puzzle dinámico se guarda completo en el snapshot para que se pueda restaurar de forma idéntica, incluso si otro dispositivo hubiera agotado el tiempo en el intento. Separar qué importa cada contexto fue otra decisión de higiene: el módulo numberlink-dynamic.js no toca el DOM ni importa paquetes, y recibe el generador del worker con una interfaz limpia. El worker es lo único que importa el solver de Tsunagi. Así, los tests de Node pueden pasar la misma implementación fijada sin levantar un navegador, y la UI no arrastra dependencias de generación que no necesita.
La unicidad como certificación, no como esperanza
La validación de un puzzle dinámico es estricta y legible. verifyDynamicPuzzle comprueba que el puzzle declara la generación, la versión del banco y la versión del generador correctas; que su validación es exact-unique; que la certificación marca unicidad; que tamaño, dificultad y semilla coinciden con lo pedido; y que los caminos son válidos, ortogonales y cubren la cuadrícula por completo. Un puzzle que no supera todas esas comprobaciones no se entrega.
La dificultad dejó de ser una etiqueta arbitraria y pasó a ser un presupuesto con forma. Cada dificultad define una longitud máxima de camino, un presupuesto de búsqueda en milisegundos, un número de intentos y unos umbrales geométricos: maxStraight (máximo de caminos rectos) y minTurns (mínimo de giros). Un puzzle «fácil» admite más caminos rectos y menos giros; un puzzle «experto» exige trazados más largos y sinuosos. La dificultad describe la forma del puzzle, no una promesa vacía. Los números concretos dejan ver esa lógica: «fácil» permite caminos de hasta 5, 5, 6 y 14 casillas para los cuatro tamaños, con un presupuesto de 2.000 milisegundos, 30 intentos, un máximo de un 58 % de caminos rectos y un mínimo de 0,45 giros por camino. «Experta» sube la longitud máxima a 12, 15, 18 y 24 casillas, concede 10.000 milisegundos y 48 intentos, y exige como mucho un 38 % de caminos rectos y al menos 1,25 giros por camino. No son cifras decorativas: son el contrato que separa un puzzle fácil de uno experto, y se pueden auditar igual que la unicidad.
Esa calibración sigue marcada como aproximada. La clasificación ordenada del proveedor es el punto de partida, pero la observación real de jugadores todavía no se ha completado. Lo dejamos escrito para no vender una certeza que no tenemos.
El presupuesto de tiempo y el respaldo
Generar en tiempo real es una apuesta contra el reloj. El worker acota los intentos y el presupuesto de búsqueda, con un timeout que da margen pero no deja al móvil esperando indefinidamente. Si el worker no consigue un puzzle certificado dentro del límite, la partida cae al banco v2 certificado. El jugador nunca se queda sin puzzle, y la calidad nunca se degrada por prisa: o hay puzzle certificado dinámico, o hay puzzle certificado del banco.
El desafío diario se mantiene deliberadamente sobre el banco v2. Necesita que todos los jugadores vean el mismo tablero el mismo día, y la determinación por semilla del banco lo garantiza sin depender de la suerte de una búsqueda dinámica. Separar el «juego libre» del «desafío diario» evita que un fallo de generación rompa la igualdad entre usuarios o la continuidad de fechas históricas.
Lo que quedó fuera a propósito
Hay cosas que decidimos no hacer en esta ronda. No reutilizamos la interfaz gráfica de Tsunagi: solo su generador y su solver, que es donde estaba el valor. No construimos todavía la UI y el registro de progreso del desafío diario general de Blupoli; el parámetro ?daily=1 funciona, pero no sustituye esa pantalla. Y no afirmamos que la generación dinámica sea equivalente al banco en todos los tamaños hasta que la comparación esté completa.
La calibración de dificultad con jugadores reales sigue pendiente. Es la clase de deuda que conviene nombrar: el sistema es determinista y certificado, pero «fácil» y «experto» todavía son aproximaciones heredadas del proveedor, no medidas nuestras. Mientras tanto, el banco sigue siendo la red de seguridad para cualquier caso en el que la generación dinámica no llegue a tiempo.
Qué cambió para quien juega
Desde fuera, el cambio se resume en una frase: Numberlink ya no rejuega las mismas dieciséis formas. El jugador encuentra caminos con giros reales, topologías distintas y la seguridad de que cada puzzle tiene una única solución, tanto si viene del banco como si se genera en el momento. La partida se guarda de forma que se restaura idéntica, y el juego del día sigue siendo el mismo para todos.
Para el mantenimiento, el resultado es igual de concreto. El banco v2 es auditable y trazable hasta un commit de origen. El generador dinámico vive en un worker que no bloquea la UI. La validación de unicidad es una comprobación explícita, no una confianza ciega en el proveedor. Y hay una herramienta, el auditor de diversidad, que permite volver a medir cualquier cambio futuro con el mismo criterio.
El resultado puede probarse directamente en Numberlink. Para el contexto de cómo Blupoli separa motores y juegos, los devlogs de generar no es resolver y un solver no sirve para todos explican la misma idea desde otras capas.
La lección: medir antes de migrar
Este recorrido deja una enseñanza que va más allá de Numberlink. Cuando un sistema produce resultados pobres, la tentación es sustituirlo por otro sistema y comprobar después si fue buena idea. Aquí hicimos lo contrario: primero definimos qué significaba «mejor» con una métrica reproducible, después encontramos un candidato que la cumplía, y solo entonces migramos, dejando siempre un respaldo certificado.
La segunda lección tiene que ver con la procedencia. Reutilizar un proyecto externo con licencia MIT no es copiar y olvidarse. Es congelar una versión, preservar la atribución, filtrar lo que no encaja con el producto y verificar con un solver propio que la garantía del proveedor se cumple. La confianza se delega, la responsabilidad no.
Y la tercera es sobre el tiempo. Generar en el navegador es una carrera contra un presupuesto que el jugador no ve pero sí siente. La solución no fue hacer el generador más rápido a cualquier precio, sino moverlo a un worker y aceptar un respaldo cuando la búsqueda no llega a tiempo. La calidad se protege con una vía de escape, no con la esperanza de que todo salga bien.
Numberlink pasó de ser un puzzle con cuatro variantes disfrazadas a un juego con variedad real y generación certificada. La parte visible es la que ve el jugador: caminos que por fin se cruzan y serpentean. La parte invisible es la que nos queda: un criterio para medir, un banco para respaldar y un worker que demuestra, en silencio, que cada puzzle tiene una única respuesta.