Blupoli's multi-product architecture looked sensible while it only had to explain one product. The repository already separated apps/web for the platform from apps/puzzles for Puzzles. Firebase Hosting already had distinct targets. The home page already consumed a canonical product registry. Shared navigation was already capable of moving between surfaces. All of that sounded like the right design. But an architecture does not prove that it can grow because its folders have generic names. It proves it when a second product arrives and the existing boundaries have to stretch without turning into a collection of exceptions.
That second product is Blupoli Cards. The work landed in PR #216 and was merged as commit 817069e. We added an independent application under apps/cards, a dedicated dist-cards build output, a third Firebase Hosting target named cards mapped to the blupoli-cards site, five initial solitaire games and the platform changes needed for blupoli.com to recognise Cards as a real product. The interesting part is not the number of files. It is which responsibilities we chose to share and which ones we deliberately kept separate.
The real problem was not implementing five solitaire games
We could have started with Klondike rules and postponed architecture until five boards were playable. That would have produced a demo, but it would not have answered the question we actually cared about: how does Blupoli add a product that belongs to the same platform without being absorbed into Puzzles? Card rules were only one part of the job. We had to decide where the code lived, how it built, how it deployed, how people discovered it, how the home page linked to it and which concerns remained part of the common layer.
The most obvious risk was reusing too much of Puzzles. Puzzles already has catalogue infrastructure, a shell, persistence, internationalisation, local analytics, engines, onboarding and a large amount of shared behaviour. Reusing all of it would have reduced the initial code count, but Cards would effectively have become a Puzzles category wearing a different domain name. The opposite risk was starting a new repository or stack and duplicating brand, CI, deploy logic, metadata, navigation and quality decisions. The solution needed to sit between those two extremes.
A new app inside the same monorepo
The main structural decision was to create apps/cards. That gives Cards a code boundary as visible as apps/web and apps/puzzles. A product does not need a separate repository in order to be independent. The monorepo remains useful because platform changes, workflows and shared pieces can evolve together, while the app boundary prevents card-specific concepts from leaking into Puzzles engines.
This also preserves a property we value: a cross-cutting launch can be reviewed as one coherent change. The pull request that introduces Cards can update the new app, the product registry, shared navigation, Firebase configuration and build gates together. If Cards lived in another repository, the same launch would require coordinating several versions and several commits before the public system reached a consistent state. The monorepo reduces that coordination problem without requiring domain code to be mixed.
Web-first, without adding a framework simply because the product is new
Cards is built with HTML, CSS and JavaScript inside the existing web architecture. We did not introduce React, Flutter, KMP or another layer just to create technological distance from Puzzles. Product independence comes from app, build, domain and hosting boundaries, not from choosing a different toolchain. A new framework would have added another dependency model, another component system and another deployment concern before we had evidence that the card collection needed any of them.
That does not mean Cards has to remain minimal forever. It means the first release can evolve from observed requirements. If later interactions, state complexity or catalogue growth justify a particular library, we can evaluate that with evidence. For now, the same basic web technology that lets us iterate quickly is enough to validate the product while keeping conceptual cost low.
One Cards runtime, five different game-state models
The first version uses apps/cards/cards.js as a shared runtime. Common utilities handle deck creation and shuffling, card representation, basic rendering, move counting, local persistence and interaction dispatch. On top of that, each game keeps its own state and rules.
Klondike needs tableau columns, stock, waste and four foundations. Spider uses ten columns, a row-deal stock and completed runs. FreeCell introduces four temporary cells and eight cascades. Pyramid models twenty-eight positions and covering relationships. TriPeaks has a different geometry and its own blocker map. Sharing utilities does not erase those differences. The runtime works because specific state is allowed to remain specific.
This is a compact version of a pattern we already know from Puzzles: share contracts and tools around mechanics rather than forcing every mechanic into one universal model. We did not attempt to build a declarative “solitaire engine” before we knew whether one would be useful. The five launch games share what we can already prove they share; the rest stays explicit.
Local persistence as the minimum useful continuity contract
Each game stores state under a blupoli-cards:<slug> key. Resume behaviour does not depend on accounts or a backend. The purpose of this first layer is straightforward: leaving a deal and returning in the same browser should not always mean starting from zero.
The model is small, but it creates a useful architectural pressure. Serializable state forces each game to define what actually constitutes a session. Klondike does not need to store DOM nodes; it needs piles, cards, selection and moves. Pyramid needs removed positions, stock and waste. TriPeaks needs removed cards and its current waste. Storing model rather than representation gives us a healthier foundation for future synchronization if that ever becomes a real feature, without pretending that remote sync exists today.
Cards becomes an explicit build phase
Creating apps/cards was not enough. The monorepo needed a deterministic deployable artifact. We added scripts/build-cards.mjs, which clears and generates dist-cards, copies the home, styles, runtime and manifest, creates five game pages from a template, and emits sitemap.xml and robots.txt.
The important decision is that Cards participates in the general repository build. npm run build is not considered complete if Puzzles and blupoli.com were generated but Cards was skipped. That removes a new failure mode before it can become normal: developers should not be able to run the standard verification command, receive a green result and later discover that the third product was outside the verified artifact.
There is also a cards:build command for isolated local work. The relationship is intentional: a product-specific build improves iteration speed, while the general build remains the publication contract.
Search foundations are generated from the beginning
The builder creates static routes for the Cards home and for /games/klondike/, /games/spider/, /games/freecell/, /games/pyramid/ and /games/tripeaks/. Each page has a canonical URL on cards.blupoli.com, a description, Open Graph metadata and VideoGame structured data. The sitemap contains those canonical URLs, and robots points to the sitemap.
We did not invent a large parallel SEO system. A new product needs a minimum discoverable surface before content begins to accumulate. Stable routes let the player-facing launch article link directly to real games and give Search Console pages it can inspect once the domain has settled. They also reduce the chance that a later migration has to repair six public URLs that launched without a convention.
Firebase: a third target, not a second project
The Firebase project remains blupoli. Separation happens at Hosting. .firebaserc already mapped the web and puzzles targets; Cards adds cards → blupoli-cards. In firebase.json, that target publishes dist-cards with its own headers, caching policy and security configuration.
This keeps operational ownership together without forcing all three sites to share output. Each surface can be deployed independently, attached to its own custom domain and use route behaviour appropriate to its product. The public address is cards.blupoli.com, while Firebase also provides the automatic blupoli-cards.web.app host.
DNS verification and certificate issuance are external to the code. The repository can be complete before Firebase finishes recognising a custom subdomain. That boundary was useful during launch: there was no reason to block build, SEO or game implementation while the hosting provider completed domain verification.
The PWA association exposed one subtle Hosting mistake
Blupoli already uses a cross-origin association so the main application can recognise the Puzzles origin. Cards added the same idea with /.well-known/web-app-origin-association and a scope extension in the blupoli.com manifest.
During integration, a small but important problem became visible: Firebase could not ignore every dotfile if we expected .well-known to reach production. The initial Cards target inherited "**/.*" in its ignore list. That would have produced a particularly misleading state: the association existed inside dist-cards and passed a casual build inspection, but Hosting would silently omit it.
We removed that ignore rule before merge and added an output check so the contradiction cannot return unnoticed. This is the sort of decision we prefer to encode as a gate. A comment saying “remember not to ignore .well-known” depends on memory. A build failure turns the same rule into a contract.
The product registry finally proves why it exists
When we redesigned the Blupoli home, we moved product inventory into content/products.json. At the time it contained only Puzzles. It was reasonable to wonder whether a data registry for one object was unnecessary abstraction. Cards answers that question. Adding the second product means adding a record with identity, URL, status, platform, translation key and preview items. The home remains a renderer.
The value is larger than avoiding duplicated HTML. The registry forces “being a Blupoli product” to have an explicit shape. Cards appears because the product system now contains a published entity, not because someone copied a visual card into the home template. That distinction becomes more valuable with a third or fourth product, when we do not want every launch to depend on remembering every surface that needs manual editing.
The home page also had to stop speaking in the singular
PR #216 already made Cards appear in the products section, but the editorial integration exposed another layer of debt. Some home copy still described Puzzles as “the first product,” and the hero still treated Puzzles as the only playable destination. Structurally, Cards existed; semantically, the home page still belonged to an earlier phase.
The complete integration therefore adds a Cards action to the hero, turns the old “future” ecosystem node into a real Blupoli Cards node, includes Cards in shared navigation and footer, updates SEO descriptions and rewrites platform copy around two products. It is a useful reminder that sentences can contain architecture too. “Our first product” can become stale in the same way as a hard-coded route.
Shared navigation grows without making the products identical
renderBlupoliHeader already accepts a list of destinations, so it did not need to learn Cards-specific rules. The web builder adds a https://cards.blupoli.com/ item with localised copy for the six current platform locales. Puzzles keeps the stronger CTA treatment because it already has its own localised product navigation; Cards joins as another first-class product destination.
The shared footer receives the same update. In addition to navigation, its lower area can expose both product domains. That prevents an editorial page written today from still presenting only puzzles.blupoli.com after Cards has shipped. Because header and footer are rendered from shared components during the build, one change propagates across the home, Blog and Devlog instead of requiring edits in dozens of source documents.
The home internationalisation layer had to understand a second product
Cards V1 is published in English, but the Blupoli home exists in six locales. The product registry uses a translationKey, so adding Cards requires EN, ES, IT, PT, FR and DE to define a category, summary and three product attributes. We also updated broader home copy so descriptions no longer talk about a platform with one product.
This validates another earlier architectural choice: semantic translation keys. Cards is not a translated HTML fragment copied into six pages. products.cards.summary is an explicit responsibility of every locale pack. If it is missing, the builder can fail instead of shipping an accidental language mix. Real product growth is a better test of an i18n model than translating a page whose structure never changes.
CI does not give Cards a weaker quality lane
The Quality and Firebase Preview workflow was extended to deploy a Cards preview on pull requests. The same PR must pass repository tests, checks, build and the existing E2E suite before we consider it ready. Firebase previews then exercise blupoli.com, Puzzles and Cards as three targets.
The first E2E attempt on the branch finished red because of an existing Numberlink test. Its onboarding dialog intercepted the “Play another” click. None of the Cards files touched that engine, and the full build had already passed. We reran the exact same commit. The second attempt completed all 119 E2E tests and all three Firebase previews. We did not simply label the failure unrelated and merge anyway; we waited for evidence that the same revision could complete the shared gate.
That detail is not dramatic, but it describes the process accurately. A shared suite can fail in another product. The purpose of a gate is not to assign blame automatically. Its purpose is to stop integration until the result is understood well enough to proceed.
New output checks make Cards difficult to accidentally remove
scripts/check-output.mjs already validated Puzzles and blupoli.com. Cards becomes a third checked target. The gate requires non-empty output, the correct manifest identity, robots pointing to the Cards sitemap, all five launch routes, canonicals on cards.blupoli.com, sitemap membership and VideoGame schema on every game page.
It also validates the Firebase target and the .well-known association. Separate source tests verify that cards.js parses as valid JavaScript, that the Cards builder declares all five slugs and that the home links to all five games. Those tests are not a substitute for deep rules testing, but they make it much harder for a refactor to ship an empty or structurally broken product while the main pipeline stays green.
What is shared today, and what remains deliberately separate
Cards shares the repository, brand, product registry, global navigation, footer, CI, Firebase project, SEO philosophy and part of the PWA integration. It keeps its HTML, CSS, runtime, game-state models, build output, Hosting site, domain and sitemap separate. That list is more informative than simply saying “we use a monorepo.” It describes the coupling we are actually accepting.
The boundary can move when evidence changes. If Puzzles and Cards eventually need the same statistics module, extracting a package may make sense. If their session models diverge too far, forcing a universal interface could be worse than maintaining two implementations. The rule is neither “share by default” nor “isolate by default.” We share when the semantics line up and keep things separate when only the technology looks similar.
The first game implementations have intentional limits
The five solitaire games are playable, but V1 is not presented as feature parity with specialised solitaire clients that have refined every variant for years. Klondike, Spider and FreeCell can grow richer sequence movement, deal options and ergonomics. Drag and drop, keyboard support, animations and smarter hints are also future areas. The launch priority was to prove the product boundary, architecture and complete publication path.
That affects how we think about reuse. Extracting a large card-game framework too early would freeze decisions made for five initial implementations. We would rather let the next round of depth reveal which patterns are genuinely common. A shared component that emerges from repeated concrete needs is usually more stable than one designed in advance for an imagined catalogue.
Blog and Devlog complete the product loop
The launch does not end with code and Hosting. The Cards home links back to the player-facing launch article and to this Devlog. The Blog explains what people can play and why these five families make a useful first collection. This piece explains the architecture and deployment boundaries. Both send readers back to Cards and its individual game pages.
That loop improves navigation and search discovery, but its main value is product memory. Someone discovering Cards can understand what it is without reading source. Someone interested in engineering can reconstruct the important decisions without walking through twenty-one commits. Git preserves exactly what changed; the Devlog preserves why those boundaries mattered.
What the second product taught us
The first lesson is that a useful multi-product architecture often shows itself through changes that are not required. We did not have to move Puzzles, create a second Firebase project or rewrite the main home. The registry accepted another entry. The general build accepted another phase. Navigation accepted another item. Firebase accepted another target. That kind of extensibility was the goal.
The second lesson is that copy and infrastructure details are architecture too. The hero's “future” node, a footer with one product domain and an ignore rule that swallowed .well-known were small assumptions that only became visible when Cards arrived. A second product is a much more honest integration test than any architecture diagram drawn beforehand.
The third lesson is that technological uniformity is not required for coherence, and technological diversity is not required for independence. Cards can use the same basic HTML, CSS and JavaScript stack as the rest of the web and still be a separate domain. Responsibility boundaries matter more than stack novelty.
What remains after the merge
PR #216 leaves a publishable foundation, not a permanently finished card product. The next phase should deepen the five games, improve touch interaction, add drag and drop where it genuinely helps, strengthen accessibility, decide which statistics are meaningful and evaluate additional variants. Cards also needs its own localisation work if we want the product itself to match the six languages of the platform home.
Search Console and the Cards sitemap can then tell us how discovery develops once the domain has enough data. There is little value in inventing a large content strategy before we know which pages receive impressions or what people search for. The technical foundation exists so we can measure and adjust, not so we can declare search work complete.
From an architecture prepared for growth to one that is actually being used
When we wrote about redesigning the Blupoli home as a product platform, the second product was deliberately an unnamed space. We did not want to invent an app simply to justify the composition. Cards now occupies that space and lets us revisit the old design with evidence. The product registry was useful. Separate app boundaries were useful. Hosting targets could grow. Shared navigation needed extension, not replacement.
That does not prove the architecture will scale forever. No pull request can do that. It proves something more concrete: moving from one product to two did not require breaking the model. That is more useful than saying the monorepo is “future-proof.” Blupoli now has a platform, Puzzles and Cards as distinct public surfaces. The architecture is no longer describing a possibility. It is being exercised.
The next product should be easier for the right reasons
Cards also gives us a benchmark for whatever comes next. A third product should not need to rediscover how to register itself on the home, how to obtain a Hosting target, how to participate in CI, how to expose canonical pages or how to connect Blog and Devlog. Those are now proven platform responsibilities. A future app should spend most of its complexity on its own domain rather than rebuilding the path from repository to public product.
That is the broader value of this work. The visible output is five solitaire games. The less visible output is a tested path for adding another Blupoli product without making the project less understandable. If that path remains simple as the products become deeper, the platform is doing its job. If future work starts accumulating special cases again, Cards gives us the first concrete reference point for recognising the drift.