Hay juegos que se integran en una plataforma porque ya sabemos exactamente qué son. Cambia el tablero, cambia el generador, quizá cambia el renderer, pero el lenguaje de diseño está bastante asentado. PolyPivot no llegó así. Llegó como una idea espacial: piezas irregulares, puntos de giro visibles, solapamientos permitidos y una solución que consiste en conseguir que todo vuelva a cubrir un rectángulo sin huecos ni superposiciones.
Eso cambia la pregunta fundamental del desarrollo. En un Sudoku puedo generar una solución, retirar pistas y comprobar unicidad. En un puzzle de caminos puedo construir una red válida y ocultar parte de la información. En PolyPivot, el problema no era solo inventar un estado final correcto. Era producir una configuración inicial suficientemente desordenada, pero que siguiera teniendo un camino legítimo hacia casa bajo una mecánica muy concreta: cada pieza solo puede rotar noventa grados alrededor de uno de sus pivotes, algunos elementos tienen dos ejes posibles y el movimiento puede sacar parte de la pieza fuera del tablero o hacerla ocupar las mismas celdas que otra.
La forma más segura de resolver ese problema fue dejar de preguntar «¿cómo encuentro una solución para este caos?» y empezar por la solución. Construir un recubrimiento válido, elegir pivotes, aplicar únicamente giros legales y guardar la secuencia inversa. El resultado no es una demostración de unicidad ni un solver universal. Es una garantía mucho más práctica: cada puzzle generado por ese proceso tiene al menos un camino conocido de regreso.
Esta entrada es el deep dive técnico del desarrollo presentado en la guía pública de PolyPivot. También conecta con dos ideas anteriores del Devlog: generar no es resolver y terminar un puzzle no es hacerlo jugable. PolyPivot obligó a poner ambas a prueba en un motor nuevo.
Diseñar desde el estado resuelto cambia el problema
Podíamos haber intentado generar piezas dispersas de manera aleatoria y después ejecutar una búsqueda para descubrir si alguna secuencia de giros las encajaba. Esa aproximación tendría una propiedad atractiva: el generador podría explorar configuraciones que no proceden de una solución conocida. También tendría un coste importante. El espacio de estados crece con el número de piezas, el número de pivotes por pieza y la posibilidad de girar en ambos sentidos. Antes de tener un juego divertido ya tendríamos un problema de búsqueda combinatoria.
La alternativa elegida invierte la dirección. Primero se divide el rectángulo en regiones conectadas. Esas regiones son las piezas resueltas. Después se asignan uno o dos pivotes a cada pieza. Finalmente se aplican giros legales a partir de ese estado, dentro de un área de trabajo ampliada. Cada giro se guarda. Para construir un camino de solución conocido basta con recorrer esos movimientos en orden inverso y cambiar el sentido.
La idea se ve directamente en el generador actual:
function generate(profile, difficultyIndex, variant = 0) {
const difficulty = DIFFICULTIES[difficultyIndex];
const seed = hash32(
`polypivot:${profile.id}:${difficulty.id}:${variant}`
);
const rng = makeRng(seed);
const regions = partitionBoard(profile, difficulty, rng);
const basePieces = regions.map((cells, index) => ({
id: index,
color: colorForPiece(index),
cells: cloneCells(cells),
solvedCells: cloneCells(cells),
pivots: pivotIndicesFor(cells, dualSet.has(index))
}));
const scrambled = scramblePieces(basePieces, profile, difficulty, rng);
return {
pieces: scrambled.pieces,
solvedPieces: basePieces,
scrambleMoves: scrambled.scrambleMoves,
reverseMoves: scrambled.reverseMoves
};
}
La semilla estable forma parte del contrato de depuración. Perfil, dificultad y variante producen el mismo puzzle. Si una combinación falla en un test o tiene un comportamiento visual extraño, se puede volver exactamente a ella. La aleatoriedad deja de ser una niebla y se convierte en una entrada reproducible.
Antes de girar nada hay que fabricar piezas razonables
Empezar resuelto no elimina el primer problema: hay que dividir el tablero completo en regiones conectadas. Una partición trivial —por ejemplo, rectángulos regulares— habría hecho el desarrollo más sencillo, pero también habría debilitado la identidad del juego. PolyPivot necesita siluetas irregulares porque el interés visual y espacial depende de reconocer cómo una pieza puede volver a ocupar su hueco después de varias rotaciones.
El algoritmo parte de varias semillas separadas. La selección de semillas favorece distancia entre ellas para evitar concentrar demasiadas regiones en un mismo rincón. Después cada región crece tomando celdas de su frontera. El generador favorece las regiones pequeñas para mantener tamaños relativamente equilibrados y aplica pequeñas preferencias de forma para no producir siempre el mismo tipo de mancha.
Hay una restricción que no se negocia: cada pieza debe permanecer conectada. El estado resuelto debe cubrir exactamente el área del tablero una vez, sin huecos y sin solapamientos. Esas propiedades se verifican después durante el self-test. No confiamos en que el crecimiento “parezca correcto” por construcción; lo comprobamos.
La dificultad influye en esa primera fase. Los perfiles actuales van de 10×8 a 16×14 y cada dificultad cambia el área media buscada por pieza. En niveles más altos aparecen más piezas pequeñas, más candidatos de scramble y una proporción mayor de piezas con doble eje. La dificultad no se reduce a “hacer más movimientos”. Modifica la geometría que el jugador tiene que leer.
El pivote principal no se elige al azar
Una vez tenemos una pieza, necesitamos decidir alrededor de qué celda puede girar. Para el pivote principal se busca la celda que minimiza la suma de distancias Manhattan al resto de celdas. No es un centro geométrico continuo; tiene que ser una celda real de la pieza. Esta heurística tiende a producir un eje razonablemente central y, por tanto, giros menos absurdos.
function pivotIndicesFor(cells, dual) {
let primary = 0;
let best = Infinity;
for (let i = 0; i < cells.length; i++) {
const score = cells.reduce(
(sum, cell) => sum + manhattan(cells[i], cell),
0
);
if (score < best) {
best = score;
primary = i;
}
}
if (!dual || cells.length < 5) return [primary];
let secondary = primary;
let far = -1;
for (let i = 0; i < cells.length; i++) {
const distance = manhattan(cells[primary], cells[i]);
if (distance > far) {
far = distance;
secondary = i;
}
}
return secondary === primary ? [primary] : [primary, secondary];
}
Las piezas de doble eje eligen el segundo pivote buscando una celda lejana del primero. Eso no garantiza que cada pareja produzca decisiones tácticas perfectas, pero sí que el cambio de eje tenga consecuencias espaciales reales. Dos pivotes casi coincidentes serían una opción técnicamente distinta y lúdicamente irrelevante.
La proporción de ejes dobles sube con la dificultad. En fácil es una excepción; en experto se convierte en parte frecuente de la lectura del tablero. Así la complejidad aumenta por posibilidades de transformación, no por esconder reglas nuevas.
Rotar es una operación geométrica pequeña con consecuencias grandes
La rotación es discreta. Para cada celda calculamos su desplazamiento respecto al pivote y aplicamos una transformación de 90 grados. Un sentido usa una permutación; el contrario usa la inversa. Después se comprueba que todas las celdas resultantes permanezcan dentro del área de trabajo.
function rotateCells(cells, pivotIndex, dir) {
const pivot = cells[pivotIndex];
return cells.map(cell => {
const dx = cell.c - pivot.c;
const dy = cell.r - pivot.r;
return dir > 0
? {r: pivot.r + dx, c: pivot.c - dy}
: {r: pivot.r - dx, c: pivot.c + dy};
});
}
function validRotation(cells, pivotIndex, dir, profile) {
const next = rotateCells(cells, pivotIndex, dir);
return next.every(cell => inWorkspace(cell, profile)) ? next : null;
}
El detalle del “área de trabajo” es importante. Las piezas pueden salir parcialmente del rectángulo objetivo durante la resolución. Si prohibiéramos todo movimiento que abandona el tablero final, muchas configuraciones quedarían excesivamente restringidas. Por eso cada perfil añade un margen. El jugador puede maniobrar fuera y volver a entrar, siempre dentro de una zona acotada.
Ese margen también convierte la condición de solución en algo más interesante que comparar arrays. Una pieza puede tener todas sus celdas dentro del workspace y seguir estando fuera del tablero objetivo. Puede estar sobre otra pieza. Puede dejar huecos. Lo que importa al final no es que cada pieza vuelva a su orientación original por identidad, sino que el tablero esté cubierto exactamente una vez.
Solapar piezas no es solo un error: forma parte del lenguaje visual
Permitir solapamientos introduce un problema de representación. Si dibujamos la última pieza encima de las anteriores, perdemos información: una superposición parece simplemente el color de la pieza superior. Si añadimos transparencia lineal, el resultado depende del orden de pintado y puede generar tonos apagados difíciles de leer.
PolyPivot mezcla los colores de las piezas que ocupan una misma celda. La función actual usa la raíz de la media de cuadrados por canal RGB. No pretende modelar pintura real ni percepción humana de forma perfecta; busca una combinación estable y luminosa que mantenga visible la presencia de varias piezas.
function mixColors(colors) {
if (colors.length === 1) return colors[0];
const rgbs = colors.map(hexRgb);
return rgbHex([0, 1, 2].map(i =>
Math.sqrt(
rgbs.reduce((sum, rgb) => sum + rgb[i] * rgb[i], 0)
/ rgbs.length
)
));
}
La mezcla convierte un estado incorrecto en información. Un solapamiento no es solo “algo está mal”; produce una señal visual local que ayuda a detectar dónde dos piezas compiten por la misma celda. El color deja de ser decoración y participa en la lectura del puzzle.
Esto también exigía una paleta suficientemente separada entre piezas. Los colores se generan recorriendo el círculo de hue con un incremento basado en el ángulo áureo y pequeñas variaciones de saturación y luminosidad. La intención no es crear una identidad permanente por pieza entre partidas, sino evitar que dos regiones vecinas acaben sistemáticamente con tonos casi idénticos.
La solución se define por ocupación, no por memoria
Cada estado calcula un mapa de ocupación: qué piezas están en cada celda, cuántas celdas quedan fuera del objetivo, cuántas posiciones tienen más de una pieza y cuántas celdas del rectángulo no están ocupadas exactamente una vez. Con esos datos la condición final es simple: cero fuera, cero solapamientos y cero huecos.
Esta decisión hace el puzzle más robusto conceptualmente. El objetivo no es reproducir una fotografía exacta del estado inicial guardado en solvedCells. Si por la geometría existiera otra disposición que cubriera el rectángulo de forma válida, también debería ser aceptable. El juego valida la propiedad que le hemos explicado al jugador, no una identidad interna que solo conoce el generador.
solvedCells sigue siendo útil durante generación y para ciertas métricas, pero no es la autoridad final. Esa separación entre “estado que usamos para construir” y “regla que define victoria” evita convertir una comodidad del algoritmo en una restricción invisible.
El scramble reversible es la garantía mínima de jugabilidad
El generador crea varios candidatos de scramble. Para cada pieza aplica un número de giros que depende de la dificultad. Cada intento elige pivote y sentido de forma pseudoaleatoria, pero solo acepta una rotación que permanezca en el workspace. Después puntúa el estado resultante: más solapamientos, piezas fuera, piezas desplazadas y cantidad de movimientos aumentan la puntuación. Se conserva el mejor candidato que no haya quedado accidentalmente resuelto.
Lo crucial llega al final:
const reverseMoves = [...best.moves]
.reverse()
.map(move => ({...move, dir: -move.dir}));
return {
pieces: best.pieces,
scrambleMoves: best.moves,
reverseMoves,
stats: best.stats
};
Cada giro legal tiene una inversa. Si partimos de una solución, aplicamos una secuencia válida y guardamos la secuencia opuesta, conocemos al menos una trayectoria de vuelta. Ese razonamiento es mucho más barato que resolver desde cero cada puzzle generado.
También tiene límites. Reversibilidad no demuestra unicidad. No demuestra que la ruta guardada sea la más corta. No garantiza que el nivel sea interesante para una persona. Ni siquiera impide que existan atajos o soluciones alternativas. Por eso PolyPivot no confunde generador con solver. El patrón encaja con lo explicado en Generar no es resolver: producir una instancia con solución conocida y demostrar propiedades globales del espacio de soluciones son problemas diferentes.
Los self-tests convierten la reversibilidad en una propiedad ejecutable
Guardar reverseMoves sería solo una promesa si nunca se comprobara. El motor incluye un selfTest() que recorre todos los perfiles, todas las dificultades y varias variantes deterministas. Cada puzzle generado pasa por una serie de invariantes.
Se comprueba que el área total de las piezas resueltas coincide con el tablero. Cada pieza debe tener al menos cuatro celdas y permanecer conectada. Los índices de pivote tienen que apuntar a celdas reales. El recubrimiento resuelto no puede solaparse y debe cubrir todas las celdas. Debe existir al menos una pieza con doble eje. El scramble no puede quedarse ya resuelto. Y, finalmente, se clonan las piezas desordenadas y se reproducen todos los movimientos inversos. El estado final tiene que cumplir la condición de solución.
Este último test es especialmente valioso porque prueba la cadena completa de supuestos: que la rotación es realmente invertible, que los pivotes siguen referenciando la geometría correcta después del scramble y que el orden inverso se ha construido bien. Es una propiedad del puzzle convertida en código, no una intuición del autor.
Deshacer y rehacer no usan la solución secreta
El jugador dispone de undo y redo, pero esas funciones no recorren reverseMoves. Mantienen snapshots de la sesión real. Antes de un giro se guarda el estado actual; al deshacer, el snapshot activo pasa a la pila de redo; al rehacer, el proceso se invierte.
La diferencia es importante. reverseMoves pertenece a generación y validación. Undo pertenece a la interacción humana. Mezclarlos habría creado un botón “deshacer” que en realidad empuja al usuario hacia el camino que el generador conoce, incluso si el jugador había encontrado una secuencia diferente. El sistema debe recordar sus propias acciones, no sustituirlas por el plan secreto del puzzle.
La pista tampoco revela el próximo movimiento de la solución guardada. Busca una pieza claramente conflictiva según celdas fuera y solapamientos. Eso mantiene la ayuda en un nivel heurístico: orienta la atención sin convertir el puzzle en una reproducción paso a paso del scramble inverso.
Completar el tablero tenía que hablar el idioma de Blupoli
PolyPivot aterrizó justo cuando el sistema de progreso estaba evolucionando hacia GameResult. Eso permitió probar si el nuevo contrato era realmente extensible. El motor conoce datos comunes —tamaño, dificultad y movimientos— y datos específicos —número de piezas y cantidad de piezas con doble pivote.
function completeIfNeeded() {
if (!isSolvedPieces(pieces, profile())) return false;
finished = true;
clearSaved();
render(false);
solved({
size: profile().label,
difficulty: difficulty().label,
moves,
pieces: pieces.length,
dualPivots: pieces.filter(piece => piece.pivots.length === 2).length
});
return true;
}
Los tres primeros datos encajan en campos comunes. Los específicos pueden acabar dentro de metadata durante normalización. El motor no necesita importar el repositorio de progreso ni conocer IndexedDB. Solo informa de lo que sabe. Este caso es exactamente el tipo de presión que queríamos aplicar al contrato común: un juego nuevo con métricas propias no debe obligar a ampliar el schema superior sin criterio.
La historia completa de esa capa está en Cuando el catálogo empezó a recordar. PolyPivot fue uno de los primeros ejemplos claros de por qué separar campos comunes y metadata merece la pena.
El onboarding rompió, y eso fue información útil
El primer commit de PolyPivot no cerró toda la historia. Poco después hubo que reparar su ayuda interactiva y alinear estado de runtime y progreso. Es un buen ejemplo de por qué un motor puede estar correcto y el producto seguir incompleto.
Una mecánica de pivotes no se explica bien con un párrafo escondido. El onboarding necesita señalar una pieza, explicar el tirafondo, mostrar que un elemento con dos ejes puede alternarlos y dejar clara la diferencia entre estar dentro del área de trabajo y estar correctamente colocado dentro del rectángulo objetivo. Cuando esa capa no monta con el runtime actual, el juego falla justo en la frontera donde el jugador aprende a usarlo.
La reparación añadió tests específicos para la ayuda de PolyPivot y se apoyó en la migración de globals de runtime hacia Blupoli. Esa evidencia vuelve a una idea del Devlog anterior Terminar un puzzle no es hacerlo jugable: detectar la solución en código no es el final. Un juego público también necesita onboarding, controles compartidos, guardado, restauración, tema, idioma, progreso y comportamiento estable dentro del host.
El namespace de marca también apareció dentro del motor
PolyPivot llegó durante la fase de eliminación de referencias activas al namespace heredado por el producto anterior. La limpieza no se limitaba al copy. El host, los globals y los estados persistidos estaban pasando a contratos de Blupoli. Eso hizo que una integración recién añadida fuera una buena prueba: si el motor dependía de un global legacy o la ayuda buscaba una clave antigua, era señal de que la migración todavía tenía una fuga.
Los fixes posteriores endurecieron el host para usar globals de Blupoli, hacer fail seguro cuando falta configuración y migrar progresivamente estados persistidos. No hace falta fingir que esas correcciones no existieron. Son parte del valor de añadir un motor nuevo durante una transición: fuerza caminos que las pruebas de unidades antiguas quizá no habían recorrido.
La misma disciplina tiene un equivalente técnico: puede haber compatibilidad temporal con claves anteriores para no perder datos, pero el código nuevo no debe producir nuevos estados bajo el namespace viejo.
La dificultad tiene varias palancas, no una sola cifra
PolyPivot define cuatro dificultades con parámetros separados. Cambia el área aproximada por pieza, la proporción de doble eje, el intervalo de giros de scramble y el número de candidatos evaluados. Esto permite que “experto” sea espacialmente más denso, tenga más decisiones de pivote y parta de un estado generalmente más alejado.
No podemos afirmar todavía que esos cuatro perfiles representen una curva psicométrica calibrada. No hay suficientes datos de jugadores para decir que un parámetro duplica la dificultad percibida o que el nivel experto tiene una tasa de resolución concreta. Lo que sí podemos afirmar desde el código es que el generador modifica dimensiones distintas del problema.
Esta honestidad importa. Un sistema procedimental puede producir niveles más complejos según una función y aun así necesitar ajuste humano. El score de scramble favorece ciertos rasgos geométricos; no mide frustración, elegancia ni el momento en que una persona ve una pieza clave y todo encaja. Esas propiedades requerirán juego real, observación y quizá telemetría agregada cuando tenga sentido.
Generar varios candidatos es una forma barata de evitar scrambles flojos
Una sola secuencia aleatoria de movimientos puede producir un puzzle técnicamente válido pero poco interesante: quizás dos giros casi se cancelan, o demasiadas piezas permanecen cerca del objetivo. En vez de diseñar un optimizador complejo, el generador prueba varios candidatos y conserva el mejor según una puntuación simple.
La estrategia es útil porque separa validez de calidad aproximada. Cualquier candidato aceptado mantiene movimientos legales y una ruta inversa. Entre esos estados válidos elegimos uno con más señales de desorden. Si en el futuro encontramos una métrica mejor —distribución espacial, distancia de piezas a sus regiones, número de decisiones útiles— podemos cambiar el score sin tocar la garantía de solvencia.
También evita convertir la heurística en un contrato imposible de modificar. El jugador nunca ve “tu puzzle tiene score 73”. Esa puntuación es una herramienta interna para elegir entre candidatos, no una verdad del producto.
El SVG no es solo un renderer cómodo
PolyPivot usa SVG para pintar el tablero y las piezas. Esa elección encaja bien con geometría de celdas, contornos y puntos de pivote. Cada pieza puede agrupar polígonos, marcas y estados de selección sin tener que rasterizar a mano. Los tornillos/pivotes se convierten en elementos interactivos identificables. El navegador mantiene nitidez en tamaños distintos.
La capa visual tiene que resolver además celdas compartidas. El mapa de ocupación ya calculado para lógica puede alimentar el color de cada posición. La misma estructura sirve para saber si una celda está libre, ocupada por una pieza o mezclada por varias. Reutilizar un concepto lógico para visualización reduce el riesgo de que el tablero “parezca” tener un solapamiento distinto del que la condición de victoria está midiendo.
Eso no significa que SVG sea obligatorio para todo Blupoli. La arquitectura del proyecto ya mezcla motores y estrategias de render según el puzzle. PolyPivot necesitaba una superficie donde geometría e interacción fueran explícitas, y SVG encajaba con esa responsabilidad.
La semilla reproducible es también una herramienta editorial
Que un puzzle pueda reconstruirse con perfil, dificultad y variante no solo ayuda a tests. Facilita capturar ejemplos, documentar un problema y comparar cambios. Si una versión del generador produce una configuración mala, podemos conservar sus entradas. Si queremos ilustrar una mecánica en una guía, podemos volver al mismo estado durante revisión.
Eso es especialmente útil en un catálogo grande, donde “a veces sale una pieza extraña en experto” es una incidencia casi imposible de perseguir. Convertir la aleatoriedad en determinismo controlable transforma una anécdota en un caso de prueba.
El coste es que cualquier cambio del algoritmo puede modificar la salida de una misma seed aunque el hash siga igual. Si en el futuro necesitamos estabilidad absoluta entre versiones, tendremos que versionar también la lógica de generación o el identificador de puzzle. Por ahora la reproducibilidad se usa principalmente dentro de una versión del motor y en sus self-tests.
Lo que todavía no demuestra PolyPivot
El generador garantiza al menos una solución conocida mediante el camino inverso. No garantiza solución única. No calcula dificultad óptima. No ha demostrado que cada nivel tenga una longitud mínima de solución. La heurística de pista no es un solver. La mezcla de color prioriza legibilidad, no exactitud colorimétrica. Y la curva de dificultad todavía es una construcción de parámetros, no una medición estadística de una comunidad grande.
Estas limitaciones no convierten el sistema en provisional; definen exactamente qué propiedades tenemos y cuáles no. Esa precisión permite mejorar sin romper contratos. Si más adelante interesa asegurar unicidad, habrá que construir análisis de estado específico. Si las pistas necesitan ser más inteligentes, podremos añadir búsqueda parcial. Si las seeds deben sobrevivir a cambios de generador, habrá que versionarlas. Ninguna de esas tareas es necesaria para afirmar hoy que los puzzles generados por el proceso actual tienen una ruta válida conocida y verificable.
Diseñar hacia atrás resultó ser la decisión más importante
El patrón de PolyPivot es útil más allá de este juego. Cuando un problema de generación es difícil de validar hacia adelante, a veces merece la pena buscar una operación reversible y construir instancias desde una solución. Eso no sustituye un solver cuando necesitamos demostrar unicidad o analizar dificultad, pero puede cambiar por completo el coste de obtener contenido jugable.
La clave es que la transformación realmente conserve las reglas del juego. Aquí cada scramble usa los mismos giros que puede hacer el jugador. No teletransportamos piezas ni aplicamos una operación secreta imposible de reproducir. Por eso la secuencia inversa tiene significado dentro de la mecánica pública.
También apareció una segunda lección: una idea de puzzle no termina en el generador. PolyPivot tuvo que entrar en la misma plataforma que los demás: onboarding, estado, undo/redo, progreso, localización, tema, contrato de resultados y gates. El motor podía ser original sin convertirse en una isla.
Ese equilibrio es probablemente el punto más interesante del trabajo. Queremos que cada juego de Blupoli tenga espacio para una mecánica específica, incluso extraña, sin que cada novedad obligue a inventar otra plataforma. PolyPivot puede tener ejes dobles, colores mezclados y generación reversible; cuando termina, sigue sabiendo decir simplemente: este juego, esta dificultad, este tamaño, estos movimientos, este resultado.
Puedes explorar el juego desde el catálogo de Blupoli Puzzles y leer la presentación pública en la guía de PolyPivot. Para entender cómo ese resultado entra en una historia común de sesiones, estadísticas y futuros retos, la pieza complementaria es Cuando el catálogo empezó a recordar.
PolyPivot empezó como una pregunta geométrica. Terminó sirviendo también como una prueba de arquitectura: un motor puede ser muy distinto por dentro y aun así comportarse como un buen ciudadano de Blupoli por fuera. Esa es exactamente la clase de diferencia que queremos conservar.