Cuando una aplicación todavía es pequeña, localStorage parece casi una ventaja injusta. Una línea guarda un objeto, otra lo recupera y el navegador hace el resto. Para una preferencia de tema o un prototipo, esa inmediatez es fantástica. El problema aparece cuando esa misma comodidad empieza a definir la arquitectura de un producto entero.

Blupoli Puzzles llegó a ese punto. El catálogo ya no era un puñado de motores aislados. Progreso, logros, rachas, finalización, onboarding y estado de partida necesitaban interpretar hechos comunes. Algunos motores guardaban directamente su propio estado. Varias capas conocían claves concretas del navegador. Había eventos browser usados como puente entre features. Y, al mismo tiempo, sabíamos que en el futuro queríamos poder introducir cuentas y sincronización sin hacer que un Sudoku o un Numberlink tuviesen que aprender qué era Firebase.

La solución fácil habría sido empezar por la nube: añadir el SDK, escribir funciones de lectura y escritura y adaptar cada feature según aparecieran necesidades. Habría producido una demo rápida de sincronización, pero también habría congelado el acoplamiento existente detrás de una segunda infraestructura. Elegimos el camino contrario. Antes de sincronizar nada, necesitábamos decidir qué significaba guardar, qué hechos cruzaban features, quién era dueño de una sesión y qué parte de la aplicación podía conocer el navegador.

Este Devlog reconstruye esa transición a partir de las PR #173, #180, #182–#190, #192, #199 y #200. No es una historia de «migramos a un framework» ni de «Firebase resolvió la arquitectura». Seguimos usando HTML, CSS moderno y módulos ES de JavaScript. El cambio importante fue establecer dirección de dependencias y convertir expectativas informales en contratos verificables.

El punto de partida: una plataforma que funcionaba, pero conocía demasiado de sí misma

La deuda no se manifestaba como una única avería. De hecho, muchas de las piezas funcionaban correctamente por separado. El problema era que cada nueva feature necesitaba saber demasiadas cosas de las demás.

Progreso conocía formatos de almacenamiento. Logros se apoyaban en señales que habían crecido alrededor de integraciones concretas. Las rachas recibían datos desde una coordinación directa. Varios motores llamaban a localStorage con claves privadas. Un evento browser como blupoli:game-result servía para comunicar capas que, conceptualmente, no necesitaban depender del DOM. Y el estado de una partida podía significar cosas distintas según el motor que lo hubiese implementado.

Ninguna de esas decisiones era absurda. Habían permitido avanzar rápido. La señal de que debíamos cambiarlas fue otra: cuando una mejora transversal exigía tocar muchas implementaciones, dejábamos de tener una plataforma y empezábamos a tener una lista de excepciones coordinadas manualmente.

Ese coste es especialmente peligroso en un catálogo de juegos. Un motor tiene derecho a ser distinto porque sus reglas son distintas. Lo que no debería variar sin motivo es la forma de guardar una partida, informar de una finalización o exponer un resultado a una feature externa. Si esas piezas también son específicas de cada motor, cualquier nueva capacidad se convierte en una migración del catálogo entero.

No queríamos una reescritura para conseguir arquitectura

La PR #173 fijó una restricción que ha guiado todo lo posterior: Blupoli Puzzles seguiría siendo una aplicación static-first basada en Vanilla HTML, CSS moderno y módulos ES. React, Vue, Angular e Ionic no eran parte de la solución. Capacitor quedaba documentado como capa de empaquetado y bridge nativo para Android, no como sistema de UI.

Puede parecer una decisión conservadora, pero tenía un objetivo concreto. La deuda que queríamos resolver no era «falta un framework». Era «las dependencias no tienen una dirección clara». Reescribir componentes en otra librería habría cambiado sintaxis sin garantizar que progreso dejase de conocer storage o que un motor dejase de escribir logros directamente.

El modelo elegido fue deliberadamente sencillo:

UI
↓
Application / Controller / State
↓
Domain

Application → Repository ports
Infrastructure → implements ports

La parte importante no son las cajas. Es la flecha. La aplicación puede pedir «carga el progreso», «guarda este snapshot» o «envía estos eventos pendientes» mediante un contrato. La infraestructura puede implementar esas operaciones con localStorage, IndexedDB o, en el futuro, Firebase. Pero Domain y los motores no deben importar hacia fuera para averiguar cómo se hace.

También aceptamos que la migración sería incremental. No había necesidad de mover físicamente todo a una estructura de carpetas ideal en una sola PR. Los paths públicos existentes podían actuar como fachadas de compatibilidad mientras la responsabilidad real se desplazaba detrás de los límites nuevos. Esa decisión redujo riesgo y evitó que «arquitectura» se convirtiera en una reorganización masiva de archivos sin beneficio funcional.

Primero tuvimos que admitir que los datos persistidos ya eran esquemas

Una de las primeras consecuencias del nuevo modelo fue dejar de tratar JSON en localStorage como si fuese memoria temporal sin contrato. En cuanto un dato sobrevive a una sesión y una versión nueva de la aplicación debe entenderlo, existe un esquema aunque no haya una base de datos tradicional.

La PR #180 introdujo una primitiva de almacenamiento JSON versionado y migraciones explícitas. El objetivo era eliminar una práctica peligrosa: leer una versión antigua, «normalizarla» silenciosamente y esperar que todos los caminos históricos acabasen en el mismo estado.

El nuevo enfoque exige cadenas completas. Si existe v1 → v2 → v3 → v4, no se salta de v1 a v4 mediante una interpretación implícita. Cada transición tiene una función conocida y testeable. Si falta un paso, es un error. El almacenamiento de progreso quedó formalizado con su esquema actual y fuentes históricas registradas; la actividad diaria hizo lo mismo.

Además, la migración no destruye inmediatamente la fuente heredada. La versión nueva se escribe, pero el origen antiguo puede conservarse hasta una limpieza explícita. Esto reduce el riesgo de convertir una migración fallida en pérdida irreversible de datos locales.

La lección fue más general que el caso de progreso: persistencia tiene semántica. No basta con que el JSON «se pueda parsear». Una versión debe poder explicar qué significa cada campo y cómo se llega desde un significado anterior.

Progress fue el primer lugar donde probamos ports de verdad

El siguiente paso fue separar orquestación y almacenamiento. La PR #183 convirtió Progress en el primer ejemplo completamente cableado del patrón Application + Repository Port + Infrastructure.

progress-controller.js pasó a coordinar iniciar, actualizar, completar y abandonar sesiones. Un contrato inward-facing describe qué operaciones de persistencia necesita. Una implementación de infraestructura para navegador compone el almacenamiento versionado con el historial detallado de sesiones. El módulo público progress.js se conserva como fachada de composición y compatibilidad, pero ya no debe convertirse en el lugar donde cualquier consumidor nuevo aprende qué clave existe en el navegador.

Este cambio parece ceremonial si se mira un solo método. ¿Por qué crear un port para una operación que hoy sólo tiene una implementación? Porque el valor no está en tener dos implementaciones hoy, sino en conseguir que la lógica de aplicación deje de depender de la única implementación actual.

Los tests ilustran el beneficio. Un controller puede ejecutarse con un repositorio en memoria sin arrancar DOM, IndexedDB ni storage real. Eso permite demostrar reglas de lifecycle sin que el entorno browser forme parte de la prueba. La aplicación se vuelve más fácil de razonar precisamente porque necesita menos mundo para ejecutarse.

Achievements y Streaks demostraron que no era un caso especial

Una arquitectura sólo es útil si el segundo y el tercer sistema pueden usarla sin copiar el primero a ciegas. Las PR #185 y #186 aplicaron la misma dirección a Logros y Rachas + Daily Challenge.

Achievements obtuvo un controller de aplicación, un port de repositorio y una implementación browser. La normalización y el merge del estado se extrajeron como lógica pura. Streaks hizo lo mismo con un controller para completados y resúmenes, un port propio y un repositorio browser que mantiene las claves existentes.

Lo importante es que no creamos un «SuperRepository» genérico para todo. Cada feature define la capacidad mínima que necesita. Progress no tiene que conocer métodos de Achievements, y Sync no necesita heredar una interfaz diseñada para datos de rachas. Los ports se parecen en dirección, no necesariamente en forma.

Esa decisión evita otra clase de deuda: una abstracción creada demasiado pronto que termina siendo más difícil de cambiar que las implementaciones que pretendía simplificar. Preferimos varios contratos pequeños y semánticos a una capa universal que sólo renombre CRUD.

El navegador dejó de ser nuestro bus de dominio

Hasta entonces, algunas features se coordinaban a través de eventos browser. Funcionaban, pero había una contradicción: «una partida se completó» es un hecho del dominio de la aplicación, no un evento de UI. No debería depender de window, CustomEvent o un nodo DOM para existir.

La arquitectura introdujo un bus de Domain Events en proceso y, más adelante, la PR #187 convirtió los eventos de lifecycle en contratos versionados. El envelope actual incluye un identificador estable, tipo, versión de contrato, versión del payload, momento de ocurrencia y payload.

game.completed y game.abandoned dejaron de ser mensajes informales. Features que sólo necesitan el resultado normalizado pueden suscribirse a una API sencilla; infraestructura que necesita metadatos de envelope puede consumir el evento completo.

Achievements y Streaks fueron migrando hacia este canal. Finalmente, el antiguo bridge browser blupoli:game-result se eliminó y se añadió un guardrail para que no reapareciera como atajo.

La diferencia no es estética. Un evento versionado puede persistirse, deduplicarse y, si llega el momento, sincronizarse. Un CustomEvent lanzado sobre window sirve para notificar dentro de una pestaña, pero no es una buena frontera para construir historia durable.

Diagrama de arquitectura donde UI y motores dependen de aplicación y dominio, mientras almacenamiento local y transporte remoto implementan ports desde infraestructura
La dirección es la parte importante: la aplicación pide capacidades; infraestructura decide si hoy las cumple el navegador y mañana un transporte remoto.

Preparar Sync significó crear un outbox que funciona incluso sin Sync

La misma PR #187 introdujo una pieza que puede parecer prematura: un repositorio local de eventos pendientes y un controller de sincronización. Sin embargo, la intención era precisamente evitar que la futura sincronización condicionase el producto actual.

Existe un outbox local, blupoli.sync-outbox.v1, que captura Domain Events versionados y deduplica por identificador estable. El controller depende de dos contratos: uno para el estado pendiente y otro para un transporte que sepa hacer pushEvents(). En la implementación actual, no hace falta que exista transporte remoto para que el outbox y el resto de la arquitectura sean válidos.

Firebase se pospuso de forma explícita. No hay SDK filtrándose por controllers, motores o features. Cuando llegue un adapter remoto, tendrá que implementar el port. Eso nos permite diseñar la semántica de sincronización sin fingir que la sincronización ya está disponible.

Esta es una diferencia que queríamos preservar también editorialmente. «Preparado para sync» no significa «los datos ya se sincronizan entre dispositivos». Significa que el lugar donde se enchufará el transporte está definido y que las capas interiores no necesitan cambiar de proveedor para usarlo.

Los motores custom también necesitaban dejar de viajar juntos

Mientras separábamos datos y eventos, la PR #182 atacó otro acoplamiento: el bundle monolítico de motores custom. Históricamente era cómodo agrupar varias implementaciones, pero abrir un juego podía implicar descargar o ejecutar código de motores no relacionados.

Se eliminó custom-engines.js y el loader pasó a resolver cada motor mediante imports dinámicos independientes. Minesweeper, Queens, Mastermind, Peg Solitaire, Hex, Order & Chaos y Pathlock quedaron registrados como módulos propios. Los motores nativos que ya tenían un camino independiente no se duplicaron por cumplir una estructura artificial.

La regla quedó automatizada: si el catálogo marca un juego como engine.type = custom, debe existir su módulo, exportar mountGame(ctx) y aparecer de forma independiente en el registry. El loader ya no tiene fallback al bundle legado.

Este trabajo está relacionado con persistencia aunque a primera vista parezca rendimiento. Si cada motor puede cargarse, probarse y evolucionar de forma independiente, también es más sencillo exigirle contratos de plataforma sin arrastrar un bloque entero de implementaciones. La modularidad de carga y la modularidad de responsabilidades empiezan a reforzarse mutuamente.

El gran hueco pendiente era el estado del tablero

Podíamos tener progreso bien separado y eventos limpios y seguir perdiendo una partida si el motor no sabía guardar su tablero. Ese era el siguiente límite real.

La PR #192 introdujo GameSnapshot v1, una capacidad gameState común y una identidad de sesión propiedad del host. El objetivo no era imponer una estructura interna a cada puzzle, sino formalizar el sobre que hace posible restaurarlo.

El host conoce la sesión y el tiempo acumulado. Infraestructura conoce el almacenamiento. El motor sólo recibe una capacidad acotada para load, save, clear y metadatos. De esta forma, una implementación ya no necesita inventar su propia clave ni llamar a APIs crudas del navegador.

También se añadió un adapter para motores pzpr y cobertura concreta para Sudoku, Numberlink y Ataxx. Las claves privadas históricas empezaron a desaparecer. Igual que con Progress, el valor estaba menos en la primera lista de juegos y más en demostrar que motores con mecánicas muy distintas podían compartir la misma frontera sin compartir el mismo estado interno.

El host debía ser dueño del tiempo o la reanudación nunca sería coherente

Una partida reanudable plantea una pregunta aparentemente sencilla: ¿cuánto tiempo lleva activa? Si el motor guarda su propio reloj y Progress guarda otro timestamp, existen dos respuestas. Si al restaurar se recrea una sesión desde cero, la estadística deja de representar el intento que el usuario realmente jugó.

Por eso la arquitectura asignó al host la identidad del intento y el elapsed time. Los motores pueden reportar acciones; no son la fuente autoritativa del reloj de plataforma. El temporizador visible y la duración persistida parten del mismo lifecycle.

Esta decisión conectó directamente con el trabajo previo de Progress, donde habíamos definido que una sesión sólo empieza con la primera interacción significativa. El snapshot puede existir antes de que una partida haya empezado estadísticamente. Restaurar un tablero no debe incrementar plays por el mero hecho de cargarlo.

Es un ejemplo útil de cómo los contratos empiezan a componer. La persistencia no inventa una semántica paralela de sesión; reutiliza la que ya había sido fijada y testeada.

Guardar un snapshot no significa serializar todo

Durante la expansión del sistema adoptamos una regla para los juegos deterministas: persistir configuración, seed o variante y el estado del jugador cuando esos datos bastan para reconstruir el puzzle. No tiene sentido guardar una estructura generada completa si el mismo generador puede reproducirla de forma fiable.

Eso reduce tamaño y, más importante, reduce duplicación de verdad. Si guardásemos simultáneamente «seed A» y una matriz completa que supuestamente corresponde a A, una migración futura tendría que decidir qué hacer si divergen. Guardar la información mínima que define la partida limita ese espacio de inconsistencia.

No todos los motores pueden hacerlo de la misma manera. La arquitectura no obliga a forzar un seed donde no existe. El principio es más general: el snapshot debe ser suficiente para restaurar la experiencia, pero no debería copiar datos derivados sin necesidad.

La restauración de una partida completada rompió una suposición cómoda

Cuando empezamos a conservar snapshots de forma más sistemática apareció un caso de borde importante. Una partida completada seguía siendo útil como estado visual: el jugador podía querer volver a verla. Pero restaurarla como si fuese una sesión pendiente provocaría efectos secundarios peligrosos.

La PR #199 convirtió el estado completado en parte explícita del snapshot. Al restaurarlo, el host lo presenta en solo lectura y no reactiva una sesión estadística. «Jugar de nuevo» es la operación que crea un intento nuevo.

El cambio corrigió además problemas concretos en Fifteen Puzzle: el estado terminado volvió a restaurarse correctamente, se eliminó un reloj duplicado y se resolvió una colisión de nombres que podía hacer que el primer movimiento activase solved().

Esta clase de bug recuerda por qué no basta con diseñar contratos en abstracto. Un sistema común se vuelve fiable cuando los casos de borde de motores reales fuerzan a precisar lo que el contrato quiere decir.

La cobertura dejó de ser optativa

Después de validar snapshots en varios motores, la PR #200 completó el salto de «capacidad disponible» a «requisito de plataforma». Los motores nativos que todavía no persistían mediante la capacidad compartida fueron migrados y se añadieron guardrails de catálogo para exigir rutas de carga y guardado en todos los juegos disponibles.

También se conservan correctamente estados completados de motores como Numberlink y PolyPivot y se expone saveState para que el host pueda forzar un snapshot final cuando la partida termina.

Esta diferencia es importante. Una arquitectura no está realmente adoptada mientras siga siendo una recomendación que un juego puede olvidar. En un catálogo grande, la cobertura tiene que convertirse en una propiedad comprobable. El CI no puede saber si una interacción «se siente bien», pero sí puede impedir que un nuevo motor publicado carezca de la capacidad mínima que el producto promete.

El último paso de storage fue eliminar accesos crudos de los motores

La consolidación de la PR #188 llevó esa regla más lejos. Catorce motores que todavía usaban localStorage directamente fueron migrados al gameState compartido. Se conservaron claves y formatos donde era necesario para no romper estado existente, pero la responsabilidad dejó de vivir dentro del motor.

Al mismo tiempo, favoritos, onboarding, tema y feedback de finalización pasaron por el adapter compartido de storage, el repositorio de sesiones se movió a infraestructura y se eliminó el path legacy que ya no debía reaparecer.

Los guardrails son deliberadamente estrictos: un motor no puede usar localStorage o IndexedDB directamente; módulos fuera del adapter autorizado no pueden introducir nuevos accesos crudos; IndexedDB permanece confinado a la infraestructura que realmente lo necesita.

El objetivo no es prohibir APIs del navegador por purismo. Es garantizar que cambiar la implementación de almacenamiento no obligue a revisar las reglas de cada puzzle.

Modernizar CSS formaba parte de la misma historia de ownership

Las PR #189 y #190 trataban responsive CSS, pero el problema de fondo era similar: ¿quién es dueño de decidir cuándo cambia un componente?

Progress y Streaks migraron buena parte de sus layouts internos a container queries. En lugar de preguntar «¿el viewport mide menos de X?», un dashboard puede reaccionar al espacio que realmente recibe dentro del shell. Los controles compartidos adoptaron sintaxis moderna de media ranges y se eliminaron breakpoints duplicados en JavaScript cuando CSS ya era la capa responsable.

Esto evita que una feature conozca detalles del dispositivo o replique una constante de layout en dos lenguajes. Igual que storage pertenece a infraestructura, la adaptación visual que depende de geometría pertenece a CSS siempre que sea posible.

No es casualidad que el nuevo shell móvil haya podido simplificarse poco después. Al reducir ownership duplicado, la interfaz puede cambiar de jerarquía sin perseguir excepciones por todas las features.

El color también dejó de ser una colección de decisiones locales

La PR #184 hizo algo parecido con la identidad visual de los juegos. Los colores de categorías y componentes pasaron a tokens semánticos y los motores dejaron de mantener paletas paralelas cuando la plataforma ya podía expresar esos estados.

Binary/Takuzu y Mini Sudoku fueron migrados para consumir variables de tablero, HUD y controles. El modo claro y oscuro se resuelve desde el tema de plataforma en lugar de permitir que un motor tenga su propio prefers-color-scheme. También se añadieron guardrails contra hexadecimales de UI en capas compartidas donde deberían usarse tokens.

Este trabajo no prepara Sync, claro. Pero sí pertenece a la misma disciplina arquitectónica: si una decisión es transversal, debe tener un dueño transversal. Cada juego puede tener personalidad, pero no necesita definir por su cuenta qué significa «superficie seleccionada en tema claro».

Local-first no es una etapa provisional que haya que esconder

Una de las decisiones más importantes de toda esta transición fue no tratar el almacenamiento local como un placeholder vergonzante hasta que llegue la nube. El navegador sigue siendo la implementación activa y debe ser buena por sí misma.

Eso significa que una partida puede reanudarse sin cuenta, los resultados detallados pueden alimentar estadísticas locales y el producto puede funcionar sin red. Una futura cuenta debería añadir continuidad entre dispositivos, no convertir en «real» algo que hoy supuestamente fuese sólo una demo.

Esta filosofía también reduce presión para introducir Firebase antes de entender el modelo. Si local-first funciona, podemos construir el transporte remoto como un adapter con criterios claros de identidad, deduplicación y conflicto. Si local-first fuese una colección de claves privadas, la nube sólo replicaría esa confusión.

Qué no hicimos, de forma deliberada

No introdujimos Firebase SDK en Domain, Application ni motores. No hicimos una migración física completa a apps/puzzles/src sólo para que el árbol de carpetas pareciese el diagrama final. No convertimos cada módulo pequeño en cinco directorios «clean architecture». No creamos un repository genérico universal. No reescribimos la UI en un framework.

También evitamos vender el outbox como sincronización terminada. No existe todavía una cuenta remota que mantenga tus partidas entre dispositivos por el mero hecho de que haya un port sync-transport. La frontera está preparada; la feature de producto no está publicada.

Estas omisiones son parte de la arquitectura. Una capa sólo merece existir si reduce acoplamiento o hace explícito un contrato real. Añadir ceremonia para anticipar todas las posibilidades futuras produciría otra forma de deuda.

Los tests dejaron de comprobar sólo resultados y empezaron a proteger fronteras

El cambio más útil en CI ha sido añadir pruebas que fallan cuando una dependencia prohibida vuelve a aparecer. Hay tests funcionales de snapshots, migraciones y lifecycle, pero también guardrails arquitectónicos.

Application no puede importar implementaciones de infraestructura. Los motores no pueden volver a storage crudo. El bridge browser eliminado no puede resucitar. Un motor custom debe tener su entrada independiente. Un juego disponible debe cumplir el contrato de persistencia. Los colores compartidos deben usar tokens donde corresponde.

Estas pruebas no sustituyen revisión de diseño. Lo que hacen es convertir decisiones costosas de volver a discutir en invariantes mecánicas. Si hemos decidido que Firebase debe vivir detrás de un port, el repositorio puede ayudarnos a recordar esa decisión incluso seis meses después.

La arquitectura nueva también mejora cómo depuramos

Separar responsabilidades reduce el radio de búsqueda cuando algo falla. Si una migración de progreso rompe datos, existe una frontera de persistencia concreta. Si un snapshot restaura mal, podemos distinguir envelope, adapter y estado del motor. Si una feature no reacciona a una finalización, hay un Domain Event que inspeccionar antes de saltar al DOM.

Antes, un bug podía manifestarse como una cifra incorrecta en Progreso y obligar a seguir una cadena que pasaba por evento browser, storage directo y lógica de engine. Ahora intentamos que cada salto tenga un contrato visible.

No elimina los bugs. La regresión de Fifteen Puzzle ocurrió precisamente durante este trabajo. Lo que cambia es la capacidad de aislarlos y convertir la corrección en una protección común para otros motores.

Una arquitectura preparada para sincronizar empieza por saber qué evento es estable

La parte que más probablemente sobreviva a una futura implementación remota es el contrato de eventos. Un sistema de sync necesita unidades que puedan identificarse, persistirse y deduplicarse. «El usuario hizo algo» no basta. «game.completed, contrato v1, payload GameResult con id estable» sí empieza a ser una unidad útil.

El outbox puede conservar ese envelope sin conocer la nube final. El transporte podrá decidir cómo enviarlo. El backend podrá validar qué acepta. Y las features locales pueden seguir consumiendo el resultado normalizado sin conocer ninguno de esos detalles.

Esta separación nos permite posponer preguntas de proveedor y seguir avanzando en preguntas de producto. Podemos decidir cómo se comporta una partida offline antes de decidir cómo se resuelve un conflicto entre dos dispositivos autenticados.

Una arquitectura preparada para móvil nativo también empieza por no acoplarse al navegador

El mismo razonamiento se aplica a Capacitor. Si algún día una capacidad necesita almacenamiento o integración nativa específica, la aplicación debería poder recibirla detrás de una frontera. No queremos que «hacer Android» signifique localizar llamadas dispersas a APIs web y sustituirlas a mano.

Eso no implica diseñar hoy adapters nativos que todavía no hacen falta. Significa conservar la dirección correcta: motores y casos de uso piden capacidades; la composición decide qué implementación está disponible en ese entorno.

La nueva experiencia compacta descrita en el artículo de Blog sobre móvil y partidas reanudables se beneficia del mismo principio. El shell adapta presentación; los motores no poseen el viewport. El host guarda sesión; los motores no poseen el storage. Son dos caras del mismo esfuerzo por reducir ownership accidental.

La evolución de Progress fue una prueba especialmente útil

Progress había sido uno de los sistemas más complejos porque mezcla agregados rápidos, sesiones detalladas, migraciones históricas, actividad diaria y visualizaciones. Después de definir que una partida empieza con la primera interacción real —el trabajo que contamos en El progreso empieza cuando juegas— quedó claro que la arquitectura debía proteger esa semántica.

Si otro módulo pudiera escribir directamente una clave de plays, volveríamos a tener dos definiciones del mismo dato. Si un motor pudiera fabricar su propio GameResult parcial, los logros y las rachas interpretarían historias incompatibles. Los ports y eventos versionados no son sólo organización: preservan el significado ganado durante esa revisión.

Esta es una razón por la que preferimos migraciones conservadoras. La arquitectura debería impedir que una capa «arregle» datos de otra inventando información que nunca se observó. Un sistema que sincronice más rápido datos ambiguos sólo distribuye la ambigüedad.

El build determinista hizo viable endurecer estos contratos

También hubo una dependencia menos obvia con el trabajo de CI. En De 34 pasos a 12: hacer que el build produzca en lugar de reparar documentamos cómo redujimos scripts y transformaciones posteriores.

Ese esfuerzo importa aquí porque una arquitectura con guardrails necesita una pipeline en la que «validar» y «reparar» no sean la misma operación. Si un check detecta que un motor usa storage crudo, no debería reescribirlo silenciosamente. Debe fallar y obligarnos a resolver la causa en la fuente.

Los contratos de arquitectura y el build determinista se refuerzan: la fuente canónica tiene responsabilidades claras y CI comprueba que no se desvíen. Cuanto menos magia post-build exista, más útil resulta una prueba que señala exactamente qué frontera se rompió.

La estructura física puede esperar; la dirección de dependencia no

El diseño a largo plazo contempla una organización más explícita dentro de apps/puzzles/src, con app, core, application, infrastructure, features, shared y games. Sin embargo, mover todos los archivos ahora habría mezclado dos tipos de cambio: dónde vive el código y de quién depende.

Elegimos resolver primero lo segundo. Una fachada puede seguir temporalmente en un path antiguo y, aun así, delegar en un controller y un repositorio bien separados. Más adelante, moverla será una operación mucho menos arriesgada porque la responsabilidad ya está definida.

Es una lección que queremos conservar: los árboles de carpetas son documentación útil, pero no garantizan arquitectura. Una dependencia invertida de verdad vale más que cinco directorios con nombres correctos y imports cruzados.

Qué ha cambiado para añadir un motor nuevo

Antes, un juego nuevo podía empezar resolviendo su mecánica y terminar acumulando decisiones periféricas: qué clave usar, cómo guardar tiempo, cómo enviar un resultado, qué hacer con tema, dónde colocar controles, cómo exponer persistencia.

Ahora la expectativa es distinta. El motor debe concentrarse en reglas, estado jugable y render. Recibe un contexto de plataforma. Usa gameState para snapshots. Expone su guardado para que el host pueda forzar un estado final. Informa de acciones y finalización mediante contratos comunes. No escribe logros, rachas ni progreso.

Esto no hace que crear un juego sea trivial. Un buen generador, solver o rival sigue siendo trabajo específico y a menudo difícil. Lo que intenta evitar es que ese trabajo específico tenga que recrear infraestructura que ya existe.

Qué ha cambiado para añadir una feature transversal

La mejora es simétrica. Una nueva feature no debería integrarse con treinta motores uno por uno si lo que necesita ya está expresado por GameResult o Domain Events. Puede escuchar hechos comunes y añadir su propio controller, estado y port si necesita persistencia.

Esto fue clave para Logros y Rachas. También lo será para cualquier futura sincronización. Cuanto más rico y estable sea el evento base, menos lógica de «si el juego es X, haz Y» termina creciendo en capas centrales.

Los metadatos específicos siguen teniendo sitio cuando son realmente específicos. La diferencia es que viajan bajo un contrato común y sólo los consumidores que los entienden deben interpretarlos.

La mejor señal del cambio es que podemos retrasar Firebase con tranquilidad

Al inicio, «todavía no hemos integrado Firebase» podía sonar como una carencia de la arquitectura. Después de esta transición, es casi la prueba contraria. Podemos seguir mejorando persistencia, reanudación, outbox y semántica de resultados sin necesitar una red activa.

Cuando llegue el momento de cuentas y sync, el trabajo difícil no empezará desde cero, pero tampoco fingimos que ya está resuelto. Quedarán decisiones reales: identidad de usuario, seguridad, reglas de Firestore o backend, conflictos entre dispositivos, orden de eventos, borrado y exportación, experiencia offline y recuperación.

La diferencia es que esas decisiones podrán concentrarse en el borde remoto. No deberían obligarnos a reescribir un Sudoku porque el proveedor de transporte cambió.

La lección más útil: prepara la nube haciendo más explícito lo local

Si tuviéramos que resumir esta fase en una sola idea, sería esa. Preparar una aplicación local-first para sincronización no consiste en rodearla cuanto antes de servicios cloud. Consiste en saber qué datos existen, quién es dueño de ellos, qué eventos son estables, qué operaciones necesita la aplicación y qué detalles pertenecen exclusivamente a infraestructura.

En Blupoli Puzzles, llegar ahí exigió más trabajo que instalar un SDK: migraciones versionadas, controllers, ports, adapters, Domain Events, outbox, módulos de motor independientes, GameSnapshot y guardrails. También exigió decir que no a algunas abstracciones y dejar Firebase para más adelante.

El resultado todavía es local-first, y eso es intencionado. La aplicación funciona sin cuenta, conserva partidas y puede ofrecer estadísticas útiles en el navegador. A la vez, la arquitectura ya tiene un lugar claro donde un transporte remoto podrá entrar sin convertirse en una dependencia de todas las capas.

La parte visible de este cambio está en Blupoli Puzzles: partidas que pueden continuar, una experiencia móvil más limpia y features comunes que se sienten menos fragmentadas. La parte invisible es la que queremos proteger durante las siguientes iteraciones: una plataforma donde añadir capacidades no obligue a cada juego a aprender cómo están implementadas.

Ese es, por ahora, el objetivo. No construir la arquitectura más ceremonial posible, sino una que nos permita seguir cambiando el producto sin que cada mejora transversal se convierta en otra migración manual del catálogo entero.