When an application is small, localStorage can feel almost unfairly convenient. One line writes an object, another line reads it back, and the browser takes care of the rest. For a theme preference or an early prototype, that simplicity is exactly what you want. The problem begins when the same convenience quietly becomes the architecture of an entire product.
Blupoli Puzzles reached that point. The catalogue was no longer a handful of isolated engines. Progress, achievements, streaks, completion feedback, onboarding and resumable game state all needed to interpret some of the same facts. Several engines still knew their own browser-storage keys. Some feature layers knew concrete persistence formats. A browser event had become a bridge between areas that conceptually did not need the DOM at all. At the same time, we already knew we wanted accounts and cross-device synchronization later without teaching Sudoku or Numberlink what Firebase was.
The tempting route would have been to start with the cloud: add the SDK, write a few reads and writes, then adapt each feature as requirements appeared. That probably would have produced a quick synchronization demo. It also would have preserved the existing coupling and wrapped it in a second infrastructure layer. We chose the opposite route. Before synchronizing anything, we needed to know what “save” meant, which facts could travel between features, who owned a game session, and which parts of the application were allowed to know about the browser.
This Devlog reconstructs that work from PRs #173, #180, #182 through #190, #192, #199 and #200. It is not a framework migration story, and it is not a “Firebase fixed our architecture” story. Blupoli Puzzles still uses static-first HTML, modern CSS and JavaScript ES modules. The meaningful change was dependency direction: turning assumptions that had grown informally into contracts the repository can now verify.
The starting point was not broken software; it was software that knew too much
There was no single catastrophic failure that forced the change. Most pieces worked on their own. The warning sign was that every new cross-cutting feature had to understand too much about everything around it.
Progress knew persistence details. Achievements had grown around signals with concrete integration assumptions. Streaks received data through direct coordination. Multiple engines wrote their own keys. A browser event such as blupoli:game-result connected feature areas even though “a game completed” is an application fact rather than a UI gesture. A saved game could mean something slightly different depending on which engine had implemented persistence first.
None of those choices was irrational. They were shortcuts that helped the product move. The reason to replace them was scale. When a shared improvement requires editing many unrelated engines, we no longer have a platform boundary; we have a list of exceptions being synchronized by hand.
That distinction matters even more in a puzzle catalogue. Engines should differ where their rules differ. A path puzzle and a competitive board game should not be forced into the same internal model. But saving an attempt, reporting completion or exposing a normalized result should not vary just because two engines were written at different times.
We wanted architecture without paying for a rewrite
PR #173 established a constraint that shaped the rest of the work: Blupoli Puzzles would remain a static-first application built with Vanilla HTML, modern CSS and JavaScript ES modules. React, Vue, Angular and Ionic were not being introduced as an architectural cure. Capacitor stayed in its intended place as a future native packaging and bridge layer for Android, not as the UI system.
That was not resistance to change for its own sake. The debt we were fixing was not “we do not have a framework.” It was “dependencies do not have a clear direction.” Rewriting a screen in a component library would not automatically stop Progress from knowing browser keys or prevent an engine from writing achievements directly.
The model we adopted is intentionally compact:
UI
↓
Application / Controller / State
↓
Domain
Application → Repository ports
Infrastructure → implements ports
The boxes matter less than the arrows. Application code is allowed to ask for capabilities such as “load progress,” “store this game snapshot” or “push pending events.” Infrastructure decides whether those operations are backed by localStorage, IndexedDB, Firebase, a network API or a native bridge. Domain and game engines should not import outward to discover the implementation.
We also chose incremental migration over a giant physical reorganization. Existing public module paths could stay temporarily as compatibility facades while the responsibility behind them moved. A future directory layout can become cleaner once the dependency graph is already clean. Renaming folders first would have produced a large diff without proving that the architecture itself had improved.
The first uncomfortable realization: browser JSON was already a schema
One early consequence of the new model was admitting that JSON in localStorage is not “just a cache” once it survives across releases. If a future version must understand it, the data has a schema whether or not it lives in a traditional database.
PR #180 introduced explicit versioned JSON storage and deterministic migration chains. The goal was to remove a risky pattern where old data could be read, normalized in one large function and silently treated as current.
The new approach requires every transition to be known. If the history is v1 → v2 → v3 → v4, code does not leap from v1 to v4 through a vague compatibility branch. Each step exists, can be tested and has a defined meaning. Progress and activity were the first durable formats to formalize that way.
Successful migration also does not have to destroy the legacy source immediately. The current representation can be written while old data remains until an explicit clear or cleanup operation. That provides a safer failure mode than “upgrade once and hope the transformation was perfect.”
The broader lesson was that persistence has semantics. Parseable JSON is not enough. A version should describe what a field means, not merely which property names happen to exist.
Progress became the first real test of repository ports
Once the schema boundary was explicit, the next question was who should orchestrate it. PR #183 turned Progress into the first fully wired example of Application + Repository Port + Infrastructure.
progress-controller.js became responsible for start, update, completion and abandonment use cases. An inward-facing repository contract describes the persistence operations that controller needs. The browser implementation composes versioned aggregate storage with detailed session history. The existing public progress.js module remains as a compatibility and composition facade, but new consumers do not need to learn browser keys from it.
At the scale of one method, that can look like ceremony. Why add a port if there is only one implementation today? Because the immediate value is not “we can swap databases tomorrow.” The value is that application logic no longer needs the current database in order to run today.
Tests make the difference tangible. A controller can be exercised with an in-memory repository without DOM, IndexedDB or real browser storage. Session rules can be proven in a smaller environment. The code becomes easier to reason about because fewer external systems are required to execute the business rule.
Achievements and Streaks proved it was a pattern, not a Progress exception
An architectural pattern is not very convincing until a second and third feature can use it without blindly copying the first. PRs #185 and #186 applied the same dependency direction to Achievements and to Streaks + Daily Challenge.
Achievements gained an application controller, a repository port and a browser implementation. State normalization and merging moved into pure feature logic. Daily streaks received their own controller and persistence port while preserving the existing browser keys behind infrastructure.
We deliberately did not create one enormous generic repository abstraction. Each feature describes the smallest capability it needs. Progress does not inherit Achievement methods. Sync does not pretend to be CRUD over the same shape as streak state. The ports share a dependency direction, not necessarily a common interface.
That choice protects us from another common form of premature abstraction: a “universal data layer” that becomes harder to change than the concrete stores it was supposed to simplify. Several small semantic contracts are easier to evolve than one vague gateway with dozens of optional methods.
The browser stopped being our domain event bus
Some feature coordination still relied on browser events. The mechanism worked, but there was a conceptual mismatch. “A game completed” is a domain fact inside Blupoli Puzzles; it should not need window, CustomEvent or a DOM node in order to exist.
An in-process Domain Event bus was introduced, and PR #187 then made game-lifecycle events explicitly versioned. The current envelope includes a stable event identifier, type, contract version, payload schema version, occurrence time and payload.
game.completed and game.abandoned stopped being informal notifications. Features that only need a normalized result can subscribe through a simple result API. Infrastructure that needs envelope metadata can subscribe to the complete versioned event.
Achievements and Streaks moved onto that channel. Eventually the old blupoli:game-result browser bridge was removed entirely, with a guardrail preventing it from being reintroduced as a shortcut later.
The practical difference is durability. A versioned domain event can be persisted, deduplicated and eventually synchronized. A CustomEvent on window is fine for local UI communication, but it is a weak boundary for building a history that needs to survive tabs, sessions or devices.
Preparing Sync meant building an outbox that remains useful without Sync
The same PR introduced something that can look premature at first glance: a local pending-event repository and a sync controller. The reason to build them now was precisely to keep future synchronization from contaminating the current product.
A local outbox, blupoli.sync-outbox.v1, captures versioned game events and deduplicates them by stable event id. The controller depends on two contracts: one for pending-event state and one for a remote transport with a pushEvents() capability. The current architecture does not require a working remote transport for the outbox to be valid.
Firebase was intentionally deferred. There is no Firebase SDK leaking through controllers, game engines or feature logic. When a remote adapter arrives, it will have to implement the transport contract rather than forcing every inner layer to know a provider.
That distinction matters in how we describe the work as well. “Ready for sync” does not mean cross-device synchronization already exists. It means the place where remote transport will connect is now explicit, and the parts inside that boundary do not need to change merely because the provider changes.
Custom game engines needed to stop travelling as one bundle too
While data and events were being separated, PR #182 addressed another form of coupling: the monolithic custom-engine bundle. Historically it was convenient to package multiple engines together, but opening one game could bring along code for unrelated games.
The legacy custom-engines.js bundle was removed. The loader now resolves each custom engine through its own dynamic import. Minesweeper, Queens, Mastermind, Peg Solitaire, Hex, Order & Chaos and Pathlock each have independent module entries. Native engines that already had their own path were not duplicated just to satisfy a diagram.
The repository now enforces that relationship: if the catalogue marks a game as custom, its independent module must exist, export the engine entry point and be registered in the loader. There is no fallback to the old bundle.
This seems like a loading concern, but it reinforces the persistence work. An independently loadable engine is easier to test against a platform contract without dragging a whole bundle into the environment. Modularity of loading and modularity of responsibility begin to support each other.
The largest remaining gap was the playable board itself
We could separate Progress and version events cleanly and still lose a player's actual board if an engine had no shared save contract. That became the next architectural boundary.
PR #192 introduced GameSnapshot v1, a common gameState capability and host-owned session identity. The goal was not to force every puzzle into one internal state model. It was to standardize the envelope and ownership required to restore a game.
The host knows the attempt and its elapsed time. Infrastructure knows the storage mechanism. The engine receives a constrained capability to load, save, clear and inspect metadata. Engines no longer need to invent private browser keys or call raw storage APIs.
A pzpr adapter and concrete coverage for Sudoku, Numberlink and Ataxx demonstrated that very different engines could share the same boundary. Private save keys began disappearing. As with Progress, the value was not the first list of migrated games; it was proving that common infrastructure did not require common game rules.
The host had to own time or resume would never be coherent
A resumable game immediately raises a simple-looking question: how long has this attempt been active? If the engine owns one timer and Progress owns a different timestamp, there are two competing answers. If restore creates a new session, the statistics no longer describe the attempt the player actually experienced.
So the host owns attempt identity and elapsed time. Engines can report actions, but they are not the authoritative platform clock. The visible timer and the stored duration come from the same lifecycle.
This decision composes directly with the earlier Progress rule that a game starts on the first meaningful interaction. A snapshot may exist before an attempt has statistically started. Merely restoring a board should not increment plays.
That is one of the benefits of clearer contracts: persistence does not invent a second session model. It reuses the model already established and tested elsewhere.
A snapshot is not an excuse to serialize everything
As coverage expanded, we adopted a practical rule for deterministic games: store configuration, seed or variant plus player state when those values are sufficient to reproduce the puzzle. There is little value in copying a complete generated structure if the same generator can recreate it deterministically.
That keeps snapshots smaller, but the more important benefit is reducing duplicate truth. If we stored “seed A” and a complete matrix supposedly generated by seed A, a future migration would need to decide which representation wins if the two diverged.
Not every engine can use a seed in the same way. The architecture does not force one where it does not belong. The principle is simply to persist enough to restore the experience without copying derived information unnecessarily.
Restoring a completed game broke a comfortable assumption
Once snapshots became a platform feature, an edge case surfaced quickly. A completed game can still be useful as visible state: players may want to return to the solved board. But restoring it as an active session would create dangerous side effects.
PR #199 made completion an explicit snapshot state. Completed boards restore read-only and do not reopen a statistical session. Play Again creates a genuinely new attempt.
The same work fixed real Fifteen Puzzle regressions: completed state was restored correctly, a duplicate local timer was removed, and a naming collision that could cause the first move to call solved() was corrected.
That is why architecture cannot be designed only on a whiteboard. Real engine behavior forces an abstract contract to answer uncomfortable questions. The contract improves because specific bugs make its semantics sharper.
Persistence coverage became a release requirement
After the snapshot system had been proven across several engines, PR #200 turned it from an available capability into a platform expectation. Native engines that still lacked shared persistence were migrated, and catalogue-level regression checks now require load and save paths for published games.
Completed states for engines such as Numberlink and PolyPivot are preserved, and engines expose saveState so the host can force a final snapshot at completion.
This is the difference between an architectural recommendation and a product guarantee. In a large catalogue, “remember to add persistence” is not enough. Coverage needs to be mechanically visible so a future game cannot accidentally ship without a capability the rest of the product assumes.
The final storage cleanup removed raw browser access from engines
PR #188 pushed the boundary further. Fourteen engines that still touched localStorage directly were migrated behind the shared game-state capability. Existing keys and formats were preserved where compatibility mattered, but ownership moved out of engine code.
Favorites, onboarding, theme and completion feedback also moved through the shared storage adapter. Session persistence was relocated into infrastructure, and legacy paths that should no longer exist were removed.
Guardrails now protect the boundary. Engines cannot introduce direct localStorage or IndexedDB access. Other modules cannot casually create new raw browser-storage dependencies. IndexedDB stays confined to the infrastructure that actually owns it.
The point is not to ban browser APIs for ideological reasons. It is to ensure that changing storage implementation does not require editing game rules.
Responsive CSS turned out to be another ownership problem
PRs #189 and #190 focused on responsive behavior, but the underlying question was familiar: which layer owns the decision that a component should change layout?
Progress and Streaks moved much of their internal adaptation to container queries. A dashboard can react to the space it actually receives instead of guessing based on the entire viewport. Shared game controls moved to modern media-range syntax, and JavaScript stopped duplicating breakpoints where CSS already owned the behavior.
That reduces another form of accidental coupling. A feature should not need to know a device class when the relevant information is simply the width of its own container. Nor should the same breakpoint constant live in CSS and JavaScript unless the behavior genuinely crosses both layers.
The later mobile shell simplification benefited from that work. Once layout ownership is less fragmented, product hierarchy can change without chasing hidden assumptions through every feature.
Color was also moved out of local implementation detail
PR #184 applied the same discipline to visual identity. Game categories and shared UI states moved toward semantic design tokens, while engines shed local palettes where the platform already had a vocabulary for the state.
Binary/Takuzu and Mini Sudoku were migrated onto shared board, HUD and control tokens. Theme behavior now comes from platform state instead of allowing an engine-specific prefers-color-scheme branch. Guardrails help prevent raw UI colors from creeping back into layers that should consume semantic variables.
This has nothing directly to do with remote sync, of course. It belongs in the same architectural story because it follows the same ownership rule: a cross-cutting decision needs a cross-cutting owner. A puzzle can remain visually distinctive without redefining what “selected surface in light mode” means.
Local-first is not a temporary phase we need to apologize for
One of the most important decisions in the entire transition was refusing to treat local persistence as an embarrassing placeholder until the cloud arrives. Browser storage remains the active implementation and needs to be good enough on its own.
That means games can resume without an account. Detailed results can power local statistics. The app can remain useful offline. A future account should add continuity across devices rather than turn a previously incomplete product into a real one.
This also removes pressure to introduce Firebase before the model is understood. If local-first behavior is coherent, a remote adapter can later focus on identity, deduplication and conflict semantics. If local state were still a pile of private engine keys, cloud sync would simply distribute that confusion to more devices.
What we deliberately did not do
We did not add Firebase SDK imports to Domain, Application or game engines. We did not perform a mass physical migration into apps/puzzles/src merely to make the directory tree match the long-term diagram. We did not split every tiny module into ceremonial layers. We did not invent one universal repository. We did not rewrite the UI in a framework.
We also did not present the local outbox as completed synchronization. There is no remote account magically keeping all games aligned between devices simply because a sync-transport port exists. The boundary is ready; the user-facing remote-sync feature is not shipped.
Those omissions are part of the architecture. A layer should exist because it clarifies a real responsibility or reduces coupling, not because a diagram says mature software must contain it.
Tests began protecting boundaries, not only outcomes
The most useful CI change has been adding checks that fail when prohibited dependencies reappear. There are functional tests for snapshots, migrations and lifecycle, but there are also architectural guardrails.
Application modules cannot import concrete infrastructure. Engines cannot go back to raw storage. The removed browser result bridge cannot quietly return. A custom engine must have an independent entry module. A published game must satisfy persistence requirements. Shared visual layers must consume design tokens where the contract says they should.
These tests do not replace design review. They turn expensive decisions we do not want to relitigate every week into repository invariants. Once we decide that a cloud provider belongs behind a port, CI can help us remember that decision months later.
The new boundaries also make debugging less ambiguous
Separating responsibility narrows the search space when something breaks. A Progress migration problem has a persistence boundary. A bad snapshot can be split into envelope, adapter and engine state. A feature that misses a completion can inspect a Domain Event before jumping into DOM behavior.
Previously, an incorrect statistic might require following a path through browser events, direct storage writes and engine-specific logic. Now the goal is for each transition to have an explicit contract.
That does not eliminate bugs. The Fifteen Puzzle regression happened during this very work. What changes is our ability to isolate the failure and turn its fix into a common protection for other engines.
Sync needs stable events before it needs a provider
The part of this architecture most likely to survive a future backend choice is the event contract. Synchronization needs units that can be identified, persisted and deduplicated. “The user did something” is not enough. “game.completed, contract v1, normalized GameResult with a stable id” begins to be a useful sync unit.
The outbox can retain that envelope without knowing the final cloud. A transport can decide how to send it. A backend can validate what it accepts. Local features can continue consuming the normalized result without knowing any of those transport details.
That lets product questions and provider questions move at different speeds. We can decide how a game behaves offline before deciding exactly how two authenticated devices resolve a conflict.
Native packaging benefits from the same dependency direction
The reasoning applies to Capacitor as well. If a future Android build needs a native storage or platform capability, the application should be able to receive it behind a boundary. “Make it Android” should not mean searching the repository for scattered browser API calls and replacing them one by one.
That does not mean designing native adapters today for features we do not yet need. It means preserving the dependency direction: engines and use cases ask for capabilities; composition decides which implementation exists in an environment.
The compact product work described in the companion Blog article on immersive mobile play and resumable games follows the same idea. The shell owns presentation; engines do not own the viewport. The host owns session persistence; engines do not own browser storage. They are two sides of the same effort to reduce accidental ownership.
Progress was a particularly useful architectural stress test
Progress had already become one of the richest systems in the application because it combines fast aggregates, detailed sessions, historical migrations, daily activity and visual analytics. After we established that a game begins on the first meaningful interaction—the work covered in Progress starts when you actually play—the architecture needed to protect that semantic decision.
If another module could write a plays counter directly, we would once again have two definitions of the same fact. If an engine could construct its own partial result shape, achievements and streaks would interpret incompatible histories. Ports and versioned events are therefore not just organizational polish; they preserve meaning earned through earlier product work.
This is also why migrations remain conservative. Architecture should prevent one layer from “fixing” another layer's data by inventing observations that never occurred. Synchronizing ambiguous data more efficiently would only distribute the ambiguity.
A deterministic build made stricter architectural contracts practical
There was another dependency that was less obvious at first: CI. In From 34 steps to 12: making the build produce instead of repair, we documented the effort to reduce post-build mutation and duplicate scripts.
That matters here because guardrails are only trustworthy when “validate” and “repair” are separate operations. If a check discovers that an engine touches raw storage, it should not silently rewrite the output. It should fail and force the source to be corrected.
Architectural contracts and a deterministic build reinforce each other. Canonical source owns the responsibility; CI verifies that the responsibility has not drifted. The less hidden mutation exists after the fact, the more useful an exact architecture failure becomes.
Physical structure can wait; dependency direction cannot
The long-term architecture describes a clearer physical home under apps/puzzles/src with app, core, application, infrastructure, features, shared and games. Moving everything immediately would have combined two different kinds of change: where code lives and what code is allowed to depend on.
We chose to solve the second first. A compatibility facade may still sit in an older path while delegating to a well-defined controller and repository. Moving it later becomes far safer because its responsibility is already understood.
That is a useful lesson for us beyond Blupoli: directory trees are valuable documentation, but they do not guarantee architecture. A genuinely inverted dependency is worth more than a beautiful set of folders containing imports that still point in every direction.
What adding a new game looks like now
Previously, a new engine could begin with its mechanic and end up collecting peripheral responsibilities: choose a storage key, decide how time works, dispatch a result, handle theme, place controls and expose persistence.
The expectation is different now. The engine focuses on rules, playable state and rendering. It receives platform context. It uses gameState for snapshots. It exposes state so the host can force a final save. It reports actions and completion through shared contracts. It does not write achievements, streaks or Progress.
That does not make game development trivial. A strong generator, solver or AI opponent remains game-specific work and can be difficult. The platform is trying to remove everything around that work that should not need to be reinvented.
What adding a cross-cutting feature looks like now
The improvement is symmetrical. A new feature should not integrate with dozens of engines individually if the fact it needs already exists in GameResult or Domain Events. It can subscribe to common facts, then introduce its own controller, state and persistence port if necessary.
Achievements and Streaks already benefited from that direction. Future sync will too. The richer and more stable the base event becomes, the less central code grows branches like “if this game is X, interpret Y differently.”
Game-specific metadata still has a place where it is genuinely specific. The difference is that it travels under a common envelope, and only consumers that understand the metadata need to interpret it.
The strongest sign of progress is that we can postpone Firebase calmly
At the beginning, “we still have not integrated Firebase” could sound like an architectural gap. After this work, it is almost evidence of the opposite. We can continue improving persistence, resume behavior, the outbox and result semantics without requiring a live network.
When accounts and remote sync arrive, the difficult work will not disappear. Real decisions remain around identity, authorization, backend or Firestore rules, conflicts between devices, event ordering, deletion, export, offline behavior and recovery.
The difference is where those decisions can live. They should be concentrated at the remote boundary rather than forcing us to rewrite Sudoku because the transport provider changed.
The lesson we want to keep: prepare the cloud by making local behavior explicit
If this phase has one reusable idea, it is that one. Preparing a local-first application for synchronization is not primarily about surrounding it with cloud services as early as possible. It is about knowing what data exists, who owns it, which events are stable, which operations the application needs, and which details belong only to infrastructure.
For Blupoli Puzzles, reaching that point required more work than adding an SDK: versioned migrations, controllers, repository ports, adapters, Domain Events, an outbox, independently loaded engines, GameSnapshot and architectural guardrails. It also required saying no to some abstractions and leaving Firebase for later.
The product remains local-first on purpose. It works without an account, can preserve games, and can provide useful personal statistics in the browser. At the same time, there is now a clear place for remote transport to enter without becoming a dependency of every feature and engine.
The visible side of this work is already available in Blupoli Puzzles: games that can resume, a cleaner compact experience and shared features that feel less fragmented. The invisible side is what we want to preserve through the next iterations: a platform where adding a capability does not require every game to learn how that capability happens to be implemented.
That is the target for now. Not the most ceremonial architecture we can draw, but one that lets the product keep changing without turning every cross-cutting improvement into another manual catalogue-wide migration.