El último Devlog de Blupoli terminó hablando de publicación: de cómo el build había dejado de ser una simple fotocopiadora de archivos y se había convertido en una frontera capaz de decidir qué parte del repositorio estaba realmente preparada para convertirse en producto público. Pocas horas después apareció el siguiente problema lógico. Saber qué publicamos no basta cuando el producto empieza a querer recordar qué hace cada persona dentro de él.
Blupoli Puzzles había crecido durante mucho tiempo como un catálogo de juegos independientes unidos por una navegación común. Esa arquitectura funciona sorprendentemente bien mientras la pregunta principal sea «¿qué puedo jugar?». Empieza a quedarse corta cuando aparecen preguntas distintas: «¿qué estaba jugando ayer?», «¿qué tipo de puzzles resuelvo más?», «¿cuánto llevo seguido entrando?», «¿qué reto me toca hoy?» o «¿puede un futuro sistema de logros reaccionar igual ante Sudoku, PolyPivot y un juego contra CPU sin conocer cada motor por separado?».
La respuesta a esas preguntas no era otra pantalla. Era una capa de producto. Y esa diferencia acabó definiendo el trabajo de esta fase: convertir la Home de Blupoli Puzzles en un lobby personalizado, transformar “Mi progreso” en un dashboard real y, sobre todo, crear un contrato común de resultados para que todos los juegos pudieran hablar el mismo idioma cuando terminan, se abandonan o registran métricas.
Esta entrada continúa directamente lo contado en Publicar no es copiar archivos. Allí el foco estaba en la frontera entre repositorio y producto. Aquí la frontera es otra: la que separa un motor de puzzle de la memoria compartida de la plataforma.
El problema no era guardar una puntuación
Cuando un proyecto tiene unos pocos juegos, es fácil pensar el progreso como un objeto sencillo: partidas jugadas, resueltos y mejor tiempo. De hecho, Blupoli ya tenía acumulados compatibles con esa idea. La dificultad apareció al mirar el catálogo completo. Algunos juegos tienen movimientos; otros no. Algunos tienen pistas; otros tienen reinicios. Los competitivos necesitan victoria, derrota o empate. Un puzzle puede tener tamaño, dificultad y semilla. Otro puede necesitar datos específicos que no justifican añadir una columna universal al contrato. Y hay un dato que parece menor hasta que quieres construir una experiencia diaria: distinguir una partida normal de un reto oficial.
La tentación más peligrosa era dejar que cada motor resolviera el problema por su cuenta. Es una solución rápida y, precisamente por eso, cara. Si un juego escribe su propio historial en IndexedDB, otro guarda estadísticas en localStorage y un tercero emite eventos personalizados, cualquier funcionalidad transversal queda obligada a aprender tres dialectos. El dashboard se convierte en un agregador de excepciones. Los logros se acoplan a motores concretos. Desde la perspectiva de aquella fase de septiembre, una sincronización remota futura también habría necesitado adaptadores por juego. Cada puzzle nuevo aumentaría el coste de mantener la plataforma.
La decisión que salió de la auditoría fue la contraria: los motores describen qué ocurrió; la capa de progreso decide cómo normalizarlo y persistirlo. Ese límite parece una cuestión de organización del código, pero en realidad define quién posee la verdad histórica del producto.
GameResult v2: una frase común para juegos distintos
El cambio central fue introducir GameResult como contrato versionado. En septiembre de 2026, el contrato estaba en la versión 2; el normalizador vigente declara la versión 3. La idea no es que todos los juegos produzcan exactamente los mismos datos, sino que compartan una estructura mínima con campos comunes bien definidos y un espacio seguro para metadata específica.
export const GAME_RESULT_SCHEMA_VERSION = 2;
export const GAME_RESULT_EVENT = 'blupoli:game-result';
export function normalizeGameResult(input = {}) {
const gameId = String(input.gameId || input.gameSlug || '').trim();
if (!gameId) return null;
const status = VALID_STATUSES.has(input.status)
? input.status
: (input.completed === false ? 'abandoned' : 'completed');
return {
id: String(input.id || input.sessionId || createGameResultId()),
schemaVersion: GAME_RESULT_SCHEMA_VERSION,
gameId,
gameSlug: gameId,
mode: VALID_MODES.has(input.mode) ? input.mode : 'puzzle',
status,
completed: status === 'completed',
result: String(input.result || input.outcome || 'completed'),
metadata: collectGameResultMetadata(input)
};
}
Hay varias decisiones interesantes en ese fragmento. La primera es la versión. Tener un número de schema obliga a asumir que el contrato evolucionará y permite reconocer datos históricos sin fingir que siempre tuvieron la forma actual. La segunda son los alias de compatibilidad: gameSlug sigue apuntando a gameId y outcome a result. No se fuerza una migración destructiva de todo el código antiguo el mismo día que nace el contrato nuevo.
La tercera decisión es quizá la más importante para escalar: metadata. No queremos añadir un campo superior nuevo cada vez que un puzzle inventa una métrica. PolyPivot, por ejemplo, puede querer informar del número de ejes dobles o de solapamientos producidos durante una sesión. Esos datos pueden ser útiles sin convertirse inmediatamente en una obligación para Sudoku, Akari o Ataxx. El criterio es sencillo: una métrica sube al contrato común cuando tiene valor transversal; mientras sea específica de una familia o motor, vive en metadata serializable.
Este modelo también evita una clase de falsedad frecuente en sistemas de estadísticas: inventar valores ausentes. Si un motor no conoce el número de movimientos, el campo queda vacío. Si un histórico antiguo no tiene duración medida, no reconstruimos una cifra a partir de promedios. En un dashboard es mejor tener un dato parcial y verdadero que una historia perfectamente rellenada pero ficticia.
La diferencia entre v2 y la versión 3 actual también deja ver cómo evoluciona el contrato sin borrar su origen. El normalizador vigente valida la identidad del juego y las fechas, y admite medidas opcionales como deshacer, rehacer o correcciones. Las duraciones y la mayoría de contadores permanecen nulos cuando no llegaron del motor; los reinicios tienen un valor inicial explícito de cero. La versión permite saber qué forma tiene el registro antes de interpretarlo. Los alias mantienen compatibilidad con nombres anteriores, pero no prometen que cualquier cambio futuro sea automático: esa evolución sigue necesitando decisiones y pruebas.
Una sesión empieza antes de que el jugador resuelva nada
Otra corrección conceptual fue dejar de tratar el “resultado” como algo que aparece únicamente al completar. La plataforma abre una sesión cuando el juego se monta. Si la persona navega a otra página, recarga o abandona sin terminar, esa sesión se cierra como abandoned. Eso permite distinguir entre «jugué una partida» y «resolví una partida», una diferencia imprescindible para calcular tasas de finalización, entender intentos y construir estadísticas que no premien únicamente los éxitos.
El host común de juegos pasó a entregar a los motores un pequeño conjunto de hooks de sesión: actualizar detalles, contar movimientos, contar pistas y registrar reinicios. Lo importante es que el motor no necesita saber nada de IndexedDB ni de la cola duradera. Su responsabilidad termina al describir el evento.
const updateSession = (details = {}) =>
game?.slug ? updateGameSession(game.slug, details) : null;
const trackMove = (count = 1) =>
game?.slug ? incrementGameSessionMetric(game.slug, 'moves', count) : null;
const trackHint = (count = 1) =>
game?.slug ? incrementGameSessionMetric(game.slug, 'hints', count) : null;
const trackReset = (details = {}) => {
if (!game?.slug) return null;
return resetGameSession(game.slug, details);
};
Este diseño no obliga a reescribir cada motor con el mismo estilo. Un motor que ya posee su cronómetro puede pasar la duración final. Otro puede apoyarse en el tiempo abierto por el host. Un juego competitivo usa markCompetitiveResult; un puzzle normal usa solved. La uniformidad aparece en el resultado persistido, no en borrar las diferencias internas que hacen que cada juego funcione.
La cola local: perder un cierre de página no puede borrar una partida
Persistir en navegador parece sencillo hasta que el usuario cierra la pestaña en el peor momento. IndexedDB es apropiado para un historial detallado, pero una transacción asíncrona iniciada justo antes de una navegación no es una promesa de eternidad. Por eso el repositorio usa una cola duradera en localStorage como paso previo. El resultado normalizado se acepta primero en esa cola y, después, se vuelca a IndexedDB.
En el hito del 19 de septiembre, el evento blupoli:game-result señalaba que el resultado había cruzado la persistencia local duradera; no afirmaba que estuviera en la nube. Esa semántica describe aquella implementación y aún separa el cierre del juego del transporte remoto. Logros y retos pueden reaccionar al contrato sin acoplarse a IndexedDB o a la red.
En septiembre de 2026, el recorrido era motor → API compartida → GameResult normalizado → cola local duradera → historial IndexedDB → dashboard/exportación. Eso describe el hito de entonces, no el backend vigente. El ADR 0004 posterior fijó Supabase como backend compartido: el progreso autenticado usa la identidad de Supabase y eventos game_events enviados mediante una RPC idempotente. Firebase permanece como Hosting/CDN. La evolución posterior no cambia el valor de la frontera local-first: jugar no dependía de la red.
Por qué no añadimos cuentas todavía
Habíamos considerado el sistema de retos diarios y rachas antes de este trabajo, y la pregunta apareció enseguida: si queremos progreso serio, ¿no deberíamos empezar por registro y perfiles? Técnicamente es una posibilidad. Productivamente era prematura. El sitio sigue en una fase en la que hay juegos por pulir, diseños por unificar y mecánicas por terminar. Introducir autenticación en ese punto habría cambiado la prioridad desde “hacer mejor la plataforma de juegos” a “mantener identidad, recuperación, seguridad, sincronización y estados de cuenta”.
La decisión de septiembre fue preparar una frontera explícita entre resultado y almacenamiento; el dashboard funcionaba sin cuenta y ofrecía exportación. Las referencias de aquella propuesta a Firestore describían una alternativa considerada entonces, no el backend actual ni el sistema desplegado. El ADR 0004 posterior estableció Supabase para cuentas y datos, con Firebase como Hosting. Fechar ambas etapas evita presentar una propuesta histórica como arquitectura vigente.
En el diseño de septiembre, el enfoque local-first mantenía honesta la interfaz: Mi progreso describía lo que vivía en ese navegador y no insinuaba una cuenta remota. La plataforma evolucionó después: la persistencia autenticada ahora sincroniza eventos con Supabase, mientras el estado de una partida sin terminar permanece local. Este pasaje registra una etapa anterior y no describe por sí solo el producto actual.
La Home dejó de ser una portada
Con una capa de progreso más útil, la Home podía hacer algo más que enseñar un conjunto de tarjetas. El rediseño la convirtió en un lobby local-first. El saludo cambia según la hora; aparece una sección para continuar jugando; se muestran juegos recientes; favoritos; recomendaciones; accesos por intención —algo rápido, relajado, desafiante o nuevo— y un resumen de progreso.
La palabra “recomendación” puede sonar más sofisticada de lo que realmente hace falta. No estamos construyendo un sistema opaco de machine learning. El código utiliza señales comprensibles: categorías que la persona juega con frecuencia, juegos todavía no probados y una penalización por repetir demasiado lo ya conocido. Esa sencillez es una ventaja en esta fase. El usuario recibe una selección personal sin que tengamos que subir su historial a un servidor ni convertir una preferencia lúdica en un perfil publicitario.
Los retos de hoy siguen la misma filosofía. Son tres selecciones que cambian cada día en el navegador. La Home puede presentar el concepto y medir qué se ha completado, mientras el nuevo contrato ya reserva isDaily para el momento en que exista un reto oficial con identidad y reglas propias. El sistema visual llega antes que el backend definitivo, pero no lo contradice.
La racha también se construye sobre actividad real. blupoli.activity.v1 mantiene un agregado diario acotado. No necesitamos leer miles de sesiones cada vez que pintamos un número en la Home. La historia detallada y los agregados rápidos resuelven problemas distintos y conviven.
“Mi progreso” necesitaba dejar de ser una cifra bonita
El segundo gran cambio visible fue el dashboard. Una sección de estadísticas es fácil de diseñar con números inventados o con tarjetas que repiten “partidas” y “mejor tiempo”. El objetivo aquí era distinto: que cada bloque respondiera a una pregunta real sobre la actividad medida.
El resumen incluye completados, partidas, tiempo de juego, días activos, racha y tasa de finalización. La tendencia permite elegir periodos y comparar completados, partidas o minutos. El mapa de actividad muestra semanas recientes. Las categorías intentan explicar qué familias se juegan más. Los récords extraen mejores tiempos y movimientos cuando esos datos existen. Los logros actuales son hitos derivados del progreso local. Y el historial lista sesiones recientes con estado, variante, duración y movimientos cuando están disponibles.
El dashboard usa funciones analíticas puras separadas del repositorio. Esa separación sigue siendo útil aunque cambie la fuente: en septiembre leía IndexedDB; en el contrato actual, los eventos autenticados se conservan en Supabase. Las gráficas reciben datos canónicos y calculan tendencias, récords y categorías sin depender directamente del backend concreto.
También se añadió una explicación explícita para jugadores existentes: los acumulados antiguos se conservan, pero el historial detallado empieza a enriquecerse a partir de esta versión. Es una decisión editorial y técnica. Podríamos fabricar sesiones retrospectivas para llenar las gráficas desde el primer día. No lo hacemos porque desconocemos fechas, duraciones y secuencias reales. Mantener el total histórico y aceptar que el detalle empieza ahora es menos espectacular y mucho más fiable.
Compatibilidad sin arrastrar la marca anterior para siempre
Esta fase coincidió con una limpieza fuerte del namespace heredado por el producto anterior. La migración de marca no podía resolverse borrando claves de almacenamiento a ciegas. Si alguien ya tenía progreso guardado bajo la clave legacy de progreso v3, cambiar simplemente al namespace actual de blupoli.progress.v3 habría convertido una mejora interna en pérdida de datos.
El runtime ahora migra los agregados antiguos al namespace de Blupoli y limpia las claves legacy después de verificar la transferencia. Lo mismo se aplicó a estado de onboarding y otros contratos compartidos. El principio es útil más allá de esta migración: compatibilidad no significa eternizar el nombre viejo en código nuevo. Significa ofrecer un camino de transición que preserve el dato y termine en el contrato actual.
Los gates añadidos alrededor del runtime comprueban precisamente eso: que los globals actuales son de Blupoli, que la migración es lossless y que los juegos disponibles participan en el contrato común. La marca deja de ser solo texto de interfaz y se convierte también en namespace técnico coherente.
El gate de cobertura cambia la definición de “juego terminado”
Una de las mejores consecuencias de GameResult no se ve en el producto. El repositorio incorpora un test de cobertura para los juegos disponibles. La intención es sencilla: un juego público no está realmente integrado si no tiene un camino verificable para cerrar su sesión en el contrato común.
Hasta ahora, “terminado” podía significar que el motor generaba tablero, aceptaba interacción y detectaba solución. A partir de esta capa, la definición se amplía. Al abrirse debe crear sesión. Si el jugador se va, debe existir abandono. Si resuelve, debe emitir solved; si compite, victoria, derrota o empate. Las métricas comunes que el motor conoce deben llegar al resultado. Y el motor no puede mantener en paralelo un historial de plataforma incompatible.
Esta exigencia encaja con el trabajo anterior de estados de publicación. Allí un juego no debía contar como disponible solo porque existiera su carpeta. Aquí un juego no debería contar como completamente integrado solo porque su tablero funcione. El producto está dejando de medir madurez por presencia de archivos y empieza a medirla por contratos satisfechos.
Lo que el contrato habilitó y lo que dejó abierto
Hay una diferencia importante entre una base que “algún día podría servir” y una interfaz que ya define puntos concretos de extensión. blupoli:game-result es uno de esos puntos. Un sistema de logros puede escuchar resultados cerrados. Un reto diario puede comprobar isDaily, fecha y puzzleId. Una racha puede alimentarse de actividad real. El sincronizador vigente recibe eventos versionados con identificadores estables y los envía a Supabase; los perfiles pueden recomponer agregados desde la historia canónica en vez de confiar en contadores dispersos.
Nada de eso obligaba a completar todas las funciones durante aquel hito. Esa era precisamente la ventaja: podíamos continuar terminando juegos y diseños mientras la plataforma empezaba a producir datos coherentes. Cuando llegó la identidad después, la conversación fue cómo sincronizar este contrato, no cómo hacer que decenas de motores explicaran por separado lo que habían hecho.
La lección transferible es sencilla: un contrato común solo debe crecer cuando la responsabilidad también lo es. Cada juego conserva sus reglas y su dominio; la plataforma define los límites concretos que permiten que colaboren sin confundir sus funciones.
Guardar una partida y guardar un resultado son problemas distintos
Otra distinción que este trabajo obliga a hacer explícita es la que existe entre el estado necesario para reanudar un puzzle y la historia que la plataforma utiliza para medir progreso. Son datos relacionados, pero no intercambiables. Un motor puede necesitar guardar la posición exacta de cada celda, una pila de undo, el RNG, piezas seleccionadas o candidatos escritos por el jugador. Ese estado pertenece al motor porque solo él sabe reconstruir una sesión interactiva.
GameResult, en cambio, no pretende reanudar nada. Describe una experiencia cerrada o abandonada con suficiente información para estadísticas, historial y sistemas transversales. Mezclar ambos conceptos llevaría a dos extremos malos: guardar snapshots enormes en el historial global o forzar al motor a perder información específica para encajar en un schema de plataforma. Por eso la documentación permite explícitamente que cada juego mantenga su save state y, al mismo tiempo, prohíbe que cree un historial de progreso alternativo que compita con la capa común.
Esta frontera sigue siendo importante ahora que existe sincronización autenticada. El producto sincroniza eventos de partidas completadas o abandonadas con Supabase, mientras las partidas sin terminar permanecen guardadas en el navegador. Son datos distintos: los resultados describen hechos terminados y versionados; un snapshot reanudable representa estado de trabajo que puede depender de una versión concreta del motor. Mantenerlos separados permite que el historial viaje sin convertir cada guardado temporal en un evento remoto.
Lo que todavía no está resuelto
El dashboard de aquel hito era útil, aunque el historial necesitaba tiempo de uso para enriquecer sus gráficas. La Home mostraba retos diarios locales, no un catálogo oficial persistente con reglas de recuperación; los logros eran hitos derivados, no un servicio global de recompensas. El backend de cuentas y progreso autenticado se incorporó después sobre Supabase, según ADR 0004 y el contrato de persistencia. Firebase es el proveedor de Hosting estático/CDN. Esta evolución es posterior y no se atribuye al cambio del 19 de septiembre.
También tendremos que vigilar el crecimiento de metadata. Un escape extensible es útil mientras no se convierta en un cajón opaco. Si varias familias empiezan a registrar la misma idea con nombres diferentes, será señal de que ese dato merece promoción al contrato común. Versionar el schema no elimina las decisiones futuras; solo nos da una forma ordenada de tomarlas.
Y queda una tarea menos glamourosa pero esencial: seguir conectando motores antiguos a métricas de movimientos, pistas y reinicios allí donde realmente las conocen. El gate garantiza el camino de cierre; la calidad de cada resultado dependerá de cuánto contexto pueda aportar cada motor sin inventar información.
La lección: una plataforma empieza cuando sus partes dejan de necesitar conocerse
Lo más importante de esta fase no es el diseño del dashboard ni el número de tarjetas nuevas en la Home. Es que un sistema de logros no necesita saber cómo resuelve Sudoku un tablero o cómo calcula PolyPivot sus pivotes. Puede escuchar un resultado normalizado. El dashboard no necesita acceder al estado interno del motor: lee historia. La sincronización vigente también recibe eventos versionados de partida, en vez de traducir el funcionamiento interno de cada puzzle. Esa capa remota llegó después del hito de septiembre y conserva la misma frontera entre juego y plataforma.
Eso es lo que empieza a convertir una colección de juegos en una plataforma: no que todo comparta el mismo código, sino que cada parte pueda cumplir su responsabilidad sin invadir las demás.
La cara visible de este cambio puede verse en la nueva Home de Blupoli Puzzles y en Mi progreso. El contexto de producto que llevó a rediseñar la experiencia está en Una nueva etapa para Blupoli Puzzles. Y el siguiente Devlog se centra en el otro gran desarrollo de estas horas: PolyPivot, un puzzle nuevo que sirvió además para comprobar si este contrato compartido podía convivir con un motor que tiene métricas y reglas propias.
El resultado no es un sistema de cuentas terminado, ni una gamificación completa, ni una promesa de nube inmediata. Es algo más útil en este momento: Blupoli ya empieza a recordar de una forma que sus juegos pueden compartir.