| name | game-factory-composition |
| description | The shared composition template that lets multiple games (Sudoku / Minesweeper) share ONE live stack โ `GameConfig<Route>` + `makeGameApp(config:)` in GameAppKit, `<Game><Concern>` target naming, and shared non-gameplay UI (Home / DailyHub skeleton / board-redirect / GC dashboard). Only the Game module is per-game; everything else is reusable module + DI config. Invoke when adding a new game, migrating a game's composition root, deciding what is shared vs per-game, writing a `GameConfig`, or when asked "how do I bootstrap game N / why is there a makeGameApp". |
Game Factory Composition
When to invoke
- Bootstrapping a new game (the "game N" / new-game-scaffold path, #479/#501).
- Migrating a game's composition root onto the shared backbone.
- Deciding whether a surface is shared or legitimately per-game.
- Writing or reviewing a
GameConfig<Route> / a makeGameApp call site.
- Reviewing a PR that touches
GameAppKit composition or adds a per-game
Live+*.swift wrapper (the latter is usually drift โ see below).
- User asks "how do I add a third/fourth game", "what does makeGameApp wire",
"what stays per-game".
This is the shipped shape of SDD-005 platform convergence (2026-06-19). The
third game (Tiles2048 / SDD-004) was prototyped then removed 2026-06-29 โ only
Sudoku + Minesweeper remain. The SDD's future DI-config refinements are not yet
here; this skill describes only what is live in GameAppKit today.
The prime shape
Only the Game module is per-game. Everything else = reusable module + DI
config. A 4th game โ fill one GameConfig + supply engine / board / tokens.
Payoff: fix/verify a bug once in the shared path, not per-app. Proven both
ways โ #544 fixed in the shared gateway fixed both apps; #536/#554 lived in
per-app-duplicated code and had to be fixed twice. The mirror principle
(minesweeper-mirrors-sudoku) is enforced structurally here, not by discipline.
Three pillars
A. Naming โ <Game><Concern> targets
- Targets are
SudokuGameState / SudokuAppComposition / SudokuPersistence,
Minesweeperโฆ. Minesweeper is the canonical clean shape to template from;
Sudoku was the drifted one and was renamed (#561/#562) + had its LiveRouteFactory
moved into SudokuAppComposition (#640) to match.
<Game>UI internals are prefix-none (don't over-prefix every file; MS
historically over-prefixed, Sudoku didn't โ settle on prefix-none).
B. Composition-as-template โ GameConfig + makeGameApp
GameConfig<Route> (in GameAppKit/GameConfig.swift) carries per-game
content only (subsystem, ckConfig, removeAdsProductId, theme, title, tints,
audio key prefix, reminder copy, homeModes) plus builder closures
(makeRouteFactory, fetchResume, makeCompletionSinks, โฆ) that receive the
wired GameDeps bag.
makeGameApp(config:) / makeGameAppWithDeps(config:) wire the entire
game-agnostic live stack once and return a ready-to-mount View. Wiring order:
1 Telemetry (+MetricKit) โ 2 ErrorReporter โ 3 GameCenter โ 4
Persistence โ 5 Monetization (AdGate/adProvider/IAP/Toast/controller) โ 6
Audio โ 7 ATT primer โ 8 Reminders โ 9 GameDeps assembled โ 10
rootVM + routeFactory + GameRoot.
- A game's
AppComposition.live() becomes a thin shell: build a GameConfig,
call makeGameAppWithDeps, mount wired.view. The existence of per-game
Live+Resume.swift / Live+Audio.swift / asymmetric Live* wrappers IS the
drift this kills โ if a migration leaves them, it's not done.
- Dep direction:
GameShellKit/GameShellUI stay zero-dep; all live seams
are imported in GameAppKit. GoogleMobileAds stays behind AdsAdMob's bridge;
CKContainer stays lazy (PrivateCKGatewayFactory).
C. Shared non-gameplay UI (all consume the config)
- Home โ universal
GameHomeView + GameHomeViewModel<Route>, built from
config.homeModes; retired each game's bespoke RootView/HomeView/VM (#557).
Universal ResumePill + ATT primer mount live here (closed #554).
- DailyHub โ the two VMs are legitimately gameplay-divergent; only the
bug-prone two-phase-load skeleton (
performDailyBootstrap) is shared in
GameShellUI (#558).
- Board redirect โ only the genuinely-shared #491 two-context decision
(
boardDestination(route:path:โฆ)) is extracted to GameAppKit (#559); view(for:)
stays per-game.
- GameCenterDashboard โ one shared dashboard (3 byte-identical copies
collapsed, #560); per-game
*LeaderboardID injected via DI.
Capabilities are UNIVERSAL, not Optional
Audio / reminders / ATT / MetricKit all wire in makeGameApp for every game.
A game missing one (e.g. MS initially shipped with no ATT primer) is a bug to
fix during its migration, NOT modelled as an Optional capability. Filling the
gap means adding the seam wiring and its L10n keys (see the scan:l10n blind
spot in apple-dev-skills:ai-translated-localization).
The recurring lesson (5ร: #558 / #559 / #560 / MS / 2048)
The SDD's "near-identical / generate-from-config / collapse N wrappers" claims
were optimistic every single time. Per-game view / VM / route enums are
legit gameplay and do NOT collapse. Audit the actual code before trusting a
spec's "near-identical" claim, extract only the genuinely-shared bug-prone
scaffolding (a ~15-line generic skeleton, not a god-VM), and surface the scope
correction to the user before implementing. Convergence scope is per-surface,
not blanket.
Adding a new game (checklist)
Related skills
apple-dev-skills:swiftpm-modularization: the leaf-core / seam / shared-UI target layout this sits on.
apple-dev-skills:telemetry-facade-pattern: how makeCompletionSinks + the GC pipeline wire in.
apple-dev-skills:swift-testing-baseline: per-target test + snapshot-gate strategy for new games.
apple-dev-skills:ai-translated-localization: the scan:l10n key-existence blind spot when a game
adopts a shared capability.