The task looked almost too easy, which was exactly the danger
Issue #309 asked for Baker’s Game in Blupoli Cards, using public rules as a reference rather than copying another site’s code or design. At first glance the implementation looked mechanical: the deal matches FreeCell, there are eight cascades, four free cells, four foundations, and every card is face up. If we only looked at state shape, copying the FreeCell module and changing a few conditions would have produced something playable quickly.
That route would also have created two engines that began almost identical. Every future correction to supermoves, foundations, capacity, autocomplete, or blocked-state detection would need to be applied twice. The real cost would not be the initial lines of code. It would be silent drift, where two copied files slowly become two interpretations of rules that should still be shared.
The better question was whether Baker’s is an entirely new engine or a specialization of a family we already had. The repository gave a strong answer. The deal, free cells, foundations, move types, capacity calculation, and win condition matched. The defining difference was how a cascade accepts a card and how an ordered movable sequence is recognized.
Before planning, we inspected the current Cards boundary
Cards had already evolved considerably from its first release. The Devlog about adding a second product describes a much smaller foundation. Since then the codebase had gained domain modules per solitaire, a central solitaire-engine.js, a shared session layer, persistence repositories, renderers, move mapping, and a larger unit and Playwright suite. Baker’s had to enter that architecture, not the historical version described by an older article.
The repository showed FreeCell isolated in apps/cards/src/domain/games/freecell.js while the central engine registered game kinds. The UI did not encode card legality; it translated selections and drops into domain moves. Persistence stored generic states by kind. The FreeCell renderer already knew how to display eight cascades, four cells, and four foundations. That distribution of responsibilities made clean reuse possible.
We also checked the architecture decision in docs/decisions/cards-solitaire-engine.md. The intended direction remained UI → session → domain, with infrastructure behind persistence. If Baker’s required the renderer to know whether two cards share a suit, we would have broken that boundary. The distinguishing rule belonged in Domain, where FreeCell’s rules already lived.
The smallest solution: a thin adapter and one explicit choice
The new bakers.js file is intentionally tiny. It reexports the FreeCell state machine: creation, move application, legal moves, capacity, blocked detection, and victory. Its job is not to pretend there is a separate engine. It gives solitaire-engine.js a module with a distinct identity for the new kind.
The actual specialization lives inside freecell.js. We added sameSuitBuild(state), which is true when state.kind is bakers. From there, canCascade and movable choose between two predicates. FreeCell keeps descending alternating-color sequences. Baker’s requires descending same-suit sequences.
We did not scatter Baker-specific branches across every move. The difference is concentrated where tableau semantics actually diverge. Moving a card into a free cell does not change. Sending a card to a foundation does not change. Supermove capacity does not change. Winning still means thirteen cards in each foundation. Sharing those parts reduces maintenance surface without hiding the rule that defines the new game.
Why we did not make Baker’s a generic FreeCell variant
Another option was to treat Baker’s as a FreeCell variant, similar to draw options in Klondike or suit counts in Spider. We could have added something like buildMode: sameSuit to a variant object. Technically that would work. Product-wise it would blur two games that need separate routes, copy, statistics, saved states, and rules.
The existing kind value is the identity that already travels through engine, persistence, routes, catalogue, and local analytics. Using that identity to select the domain rule keeps implementation and product aligned. A variant should describe options inside one game; here we needed a separate game that happens to share an implementation family.
This also prevents ambiguous saved games. A state stored as FreeCell should never resume under Baker’s rules because a preference changed. A state stored as Baker’s always comes back with same-suit building. Code reuse does not require product identity to be reused as well.
The shared deal did not need to know the game name
One important check during review was how FreeCell creation handled identity. The create function returns cascades, cells, and foundations without hardcoding kind: freecell. The outer createGame function adds the game identity. That separation is what allows Baker’s to reexport creation safely.
If freecell.create() had written a fixed kind, reexporting it from bakers.js would have been a subtle bug: the game would be created as FreeCell and sameSuitBuild would never activate. Checking the real code before assuming behavior was a direct application of the project’s development rule: the repository is the source of truth, not our memory of how a module ought to work.
The current contract is cleaner. A game module creates the specific board data; the engine wrapper adds version, kind, seed, variant, move count, and win state. Baker’s can share creation because identity already belongs one layer above it.
Supermove capacity stays exactly the same
A tempting “quality of life” shortcut would have been to let any valid same-suit sequence move as a block regardless of available cells. That would make dragging easier, but it would change the game. FreeCell-family multi-card moves model the temporary storage provided by free cells and empty cascades, so the maximum group size depends on remaining space.
The existing moveCapacity method already calculates this from the number of open cells and empty cascades, adjusting when the destination itself is empty. Baker’s reuses the same calculation. Only the test for whether a sequence is ordered changes before its length is compared with capacity.
This is semantic reuse rather than code reuse for its own sake. We share the method because the rule truly matches. If another future solitaire uses a different notion of multi-card capacity, forcing it through this method simply to avoid one more function would be the wrong abstraction.
The empty-cascade rule had to be a documented product decision
Baker’s appears in implementations with different restrictions around empty columns. For Blupoli we chose the open classic form where an empty cascade accepts any legal card or sequence, still subject to normal supermove capacity. The decision is written in the SDD plan, player-facing copy, and tests.
Implementation is almost invisible because canCascade already accepts an empty destination. That does not make the decision unimportant. An inherited behavior can be correct by accident or correct by contract. Writing it down turns it into something a future refactor knows it must preserve.
We also avoided adding a “Kings only” option without a product need. Every extra rule combination adds state, copy, tests, and UX surface. One well-defined version is more valuable than a variant selector whose main purpose would be demonstrating that the code is configurable.
Registering the engine was only half the integration
Adding bakers to GAME_KINDS and the module map is enough to create a state, but a Cards game crosses more layers. Foundation autocomplete had to recognize Baker’s as part of the family. The move mapper had to build cascade, free-cell, and foundation moves. The renderer needed eight columns. Selection normalization had to validate free cells and cascades the same way as FreeCell.
Rather than create new branches of code everywhere, we expanded conditions where a real family already existed. Whenever the code said “if this is FreeCell,” we asked whether the semantic meaning was actually “if this game uses the FreeCell cascade-and-cell model.” When the answer was yes, the condition became FreeCell plus Baker’s. When it was no, nothing changed.
This prevents a common form of drift. If we had copied the renderer, a later accessibility fix or card-size change could reach one game and miss the other. They share a rendering surface because their geometry is genuinely the same, not because reuse is fashionable.
Double click, drag, tap, and keyboard had to remain one interaction system
Cards does not consider a game complete just because the domain accepts moves. Shared interaction supports tap-to-select, drag, double-click or double-tap to foundation, and keyboard shortcuts for common actions. Baker’s needed to enter those paths without acquiring a special event layer.
The move mapper was a key point. Two branches that previously checked state.kind === freecell were widened to the FreeCell/Baker’s pair. A drop from cascade to cascade, cell to cascade, or toward a foundation therefore creates the same domain move types. Final legality remains inside Domain.
That separation is why the pointer layer does not need to know whether a seven of hearts belongs on an eight of spades. It expresses the intent “move this source to that cascade,” and the engine decides. A future rule change should not require editing pointer listeners.
The browser test exposed a selection detail worth preserving
The Baker’s E2E test builds a tiny deterministic state: a 7♠–6♠ sequence, a valid 8♠ destination, and an invalid 8♥ destination. It selects the source and attempts the bad drop first, expecting the move count to stay at zero. Then it selects the source again and uses the eight of spades, expecting one move and the lower card at its new location.
Reselecting the source is intentional. When an invalid drop ends on another selectable card, the current UI changes selection to that clicked target. An early version of the test assumed the original source remained active, which would have failed because the test misunderstood interaction state rather than because Baker’s rules were wrong.
We changed the test to match the actual UI instead of changing the UI only to satisfy a new scenario. If selection semantics should change later, that is a family-wide UX decision, not an exception for Baker’s.
The first CI failure belonged to the test, not the game
The first Quality and Firebase Preview run for PR #312 reported 273 passing tests and one failure. The new case named “Baker’s Game builds same-suit sequences and keeps classic empty cascades open” threw ReferenceError: getLegalMoves is not defined. The assertion logic was fine; the test file simply forgot to import the function from solitaire-engine.js.
The workflow stopped before Cards preview and browser E2E, so those later steps were skipped. That is the gate doing its job. A source suite that is not green should prevent us from spending a preview as if the branch were already validated. The fix was tiny—add the missing import—but the feature remained unfinished until automation could prove the new state.
It was also a useful reminder that “trivial failure” must not become “ignorable failure.” Once a process allows merges around red checks because the explanation sounds obvious, the checks stop being an actual contract.
While Baker’s was being validated, Montana landed first
Baker’s was not the only Cards expansion in flight. Montana Solitaire, PR #310, started from the same base and reached main first. That made PR #312 diverge in exactly the files catalogue features tend to share: the engine registry, game copy, translations, builder, tests, and E2E lists.
The correct response was not to choose our files over Montana’s. We moved Baker’s onto current main and reapplied the feature while preserving Montana. Counts and copy had to describe the resulting catalogue. Test arrays needed both slugs. Builder symbols and engine maps needed both modules. A conflict-free text merge would not have been enough if it produced a product-level contradiction.
This is why catalogue changes need semantic review. Two branches can merge syntactically and still leave a heading that announces eleven games while the source of truth contains twelve. The final diff has to be read as a product, not only as a set of resolved lines.
A second race happened during the final integration: Russian Solitaire (#311) also reached main. We applied the same discipline again, making Baker’s the thirteenth game. Counts, E2E lists, and the engine registry were reconciled from the published state while preserving Montana, Russian, and the shared card redesign that had landed in between.
Even the branch cleanup had a visible collaboration side effect
To remove the original divergence cleanly, the Baker’s branch was temporarily moved to the same commit as main before the feature was reapplied. During that exact moment GitHub saw a pull request with no diff and automatically closed #312. No code was lost, but the collaboration state changed.
The reapplied commits still existed on the branch. The remaining work was to finish the reconciliation, inspect the real branch head, and reopen the pull request once it again contained a meaningful diff. The event has nothing to do with solitaire rules, but it belongs in the development story because delivery state is part of the system we operate.
A repository can contain correct files while the PR is accidentally closed or based on stale main. That is why our definition of done includes Issue, branch, pull request, Actions, merge, and final main verification rather than stopping at “the code is present somewhere.”
Catalogue and SEO are product code too
Baker’s adds rules and descriptions in English, Spanish, Italian, Portuguese, French, and German. Cards routes are generated from GAME_COPY, so registering the slug produces localized pages, canonical links, hreflang, sitemap entries, and catalogue cards. The builder also needs its visual symbol and coherent top-level descriptions.
This can look peripheral next to canCascade, but a feature that only exists in the engine is not published. Players need to discover it, read the rules in their interface language, and reach a stable URL. The architecture makes much of that integration declarative, but the pull request still owns the correctness of the data.
The paired Blog guide about how Baker’s changes FreeCell strategy provides the public explanation that does not belong inside a compact rule panel. The game teaches the rules, the Blog explores play, and this Devlog preserves the engineering decisions.
Tests focus on the difference rather than restating the similarity
A test that only checked whether Baker’s deals fifty-two cards would prove the part we already share with FreeCell. The valuable coverage lives at the boundary: a 7♠–6♠ sequence may move onto 8♠; the same sequence may not move onto 8♥; an empty cascade remains valid; and a 7♠–6♥ alternating-color pair does not become movable just because FreeCell would accept its color pattern.
We also included Baker’s in near-finished autocomplete coverage so the shared foundation logic has to complete the correct game kind. Catalogue and build tests require the slug to be published. Playwright visits it on desktop and at a 390-pixel touch viewport so the shared eight-column layout is exercised as part of the real product.
The principle is useful beyond this game: the more implementation two features share, the more new tests should concentrate on the behavioral distinction that a future refactor might accidentally erase.
The SDD documents are intentionally small
The Baker’s plan records the objective, decisions, and implementation steps. The task file separates the FreeCell family work, engine integration, interaction, catalogue, and verification. We did not turn the documents into a second copy of the source code. Lightweight SDD is useful when it forces decisions and leaves an auditable map, not when every function appears twice.
Two decisions were important enough to preserve explicitly: empty cascades accept any legal card or sequence, and Baker’s shares the FreeCell family rather than cloning it. Those statements explain the design and should remain meaningful even if internal function names change later.
After Montana landed, the documents also needed their catalogue context updated. A plan that keeps calling Baker’s the eleventh game after main has already changed becomes misleading historical evidence. Documentation is part of the merge, not a frozen artifact from the first hour of the branch.
What we deliberately did not build
We did not create a universal solitaire engine described by JSON. We did not add a class hierarchy for FreeCell and Baker’s. We did not invent general flags for every conceivable tableau rule. We did not duplicate the renderer. And we did not preserve compatibility with an older Cards architecture that the repository no longer uses.
The chosen solution is more modest: one shared family with a clearly named domain choice. That may sound less ambitious, but it fits the evidence we have. If future games reuse the same model with more independent differences, we will have concrete pressure to extract a more formal strategy. Today we do not need to predict that shape.
Avoiding premature abstraction also keeps the code readable. Someone opening freecell.js can see quickly what changes for Baker’s. They do not need to follow a chain of factories and policy objects to discover that the core distinction is alternating colors versus same suit.
Final verification matters more than a tidy-looking diff
An implementation can look elegant in review and still fail because of an import, a missing generated route, or an E2E selector. Blupoli treats GitHub Actions as the primary gate where it already covers source tests, build, and browser behavior. We do not manually rerun the same checks merely to create a second copy of green evidence.
Manual review focuses on what automation cannot judge reliably: whether the chosen variant is described clearly, whether parallel catalogue work has been preserved, whether dead code or accidental changes entered the diff, and whether the relationship between Baker’s and FreeCell still makes sense to a maintainer.
The task only closes when the pull request is based on current main, relevant checks pass, the merge succeeds, and main contains the expected files. Shipping the game without that complete chain would contradict the reason we invested in a verifiable platform in the first place.
The lesson: share behavior, not identity
Baker’s gave us a boundary we will probably reuse. Two games can share deal logic, state shape, move types, UI, persistence, and capacity calculation without becoming one catalogue entry. Identity belongs to the product; implementation may share everything whose semantics genuinely match.
The goal is not DRY at any cost. Sharing a rule that is not actually equal would create a neat engine and the wrong game. Copying everything to preserve identity would create avoidable maintenance duplication. The useful middle ground is to share stable contracts and specialize the behavior that defines the game.
For players the result is Baker’s Game, a table that feels native to Cards while making different decisions from FreeCell. For the codebase the result is more interesting: a new game added one new rule instead of a second full engine. That is the kind of growth we wanted when Cards began evolving from a small launch into a collection that can expand without multiplying debt.
Related reading
The product boundary that made Cards possible is covered in From one product to two. The verification approach is explored in From games to a verifiable platform, and localization gates in Six-language i18n and quality gates. For the player side of the same release, read Baker’s Game: when FreeCell starts thinking in suits.