# ARCHITECTURE.md — SoliCards ## Overview SoliCards is a native SwiftUI solitaire app supporting three game variants (Klondike, Spider, FreeCell) across iOS, iPadOS, and macOS from a single codebase. It was ported from a 2,843-line vanilla JavaScript web app to native Swift. ## Pattern: MVVM + Protocol-Oriented Strategy ### Why MVVM SwiftUI's `@Observable` macro (iOS 17+) gives property-level observation — a score change does not re-render the tableau. This is critical for a card game with many rapidly-changing UI elements during drag operations. ### Why Strategy Pattern for Variants The three variants share concepts (cards, foundations, tableaus, undo, scoring) but have radically different rules. A single `GameRules` protocol defines the contract: ``` GameRules (protocol) ├── KlondikeRules — alternating colors, draw 1/3, foundation A→K ├── SpiderRules — same-suit sequences, 2-deck, auto-complete K→A └── FreeCellRules — power moves: (1+freecells) × 2^emptyTableaus ``` Adding a new variant means implementing one struct + one board view. Zero changes to existing code. ### Why Not TCA The game has no networking, no side effects beyond sound and persistence, and all state mutations are synchronous UI gestures. TCA's unidirectional architecture would add overhead without benefit. ## Key Architectural Decisions ### 1. DragGesture, not onDrag/onDrop SwiftUI's `onDrag`/`onDrop` uses the `Transferable` protocol, which serializes data through the system pasteboard. This is designed for inter-app drag and drop. For internal card movement: - `DragGesture` gives direct control over positioning and animation - `DropTargetPreferenceKey` reports frame geometry for hit-testing - `DraggedCardsOverlay` renders floating cards in the board's coordinate space - No serialization overhead ### 2. @Observable, not ObservableObject The `@Observable` macro (Observation framework) was chosen over `ObservableObject`/`@Published` because: - Property-level observation — only views reading a changed property re-render - No `@Published` boilerplate - Works with `@Bindable` for two-way bindings - Cleaner nested observable objects ### 3. @MainActor on ViewModels, not actors for game logic All game state mutations are triggered by user gestures on the main thread. Using an actor would force every game action to be `async`, adding `await` noise for no concurrency benefit. Only `SoundManager` uses async for off-main-thread audio loading. ### 4. Value-type GameSnapshot for undo Undo is implemented via an array of `GameSnapshot` structs (Codable value types). Each snapshot is a deep copy of all card arrays, score, and moves. This avoids reference-type aliasing bugs that would occur with class-based state. The history caps at 20 entries. ### 5. PNG images from asset catalog, not Canvas drawing The source project already had 52 card front PNGs in multiple styles. Re-drawing cards procedurally would be wasted effort. Asset catalog images are: - Memory-mapped by the system (efficient loading) - Automatically thinned for App Store builds - Validated at compile time ### 6. Debounced auto-save Game state is saved via SwiftData after 2 seconds of inactivity (not on every move). This prevents excessive disk writes during rapid play. Immediate save occurs on app background and pause. ### 7. trailingSuffix(while:) extension Swift's standard library does not include `suffix(while:)` on `BidirectionalCollection`. Rather than adding a dependency (Swift Algorithms), a small `trailingSuffix(while:)` extension on `Array` provides the same functionality for finding face-up card runs. ## Data Flow ``` User Gesture (tap / drag) │ ▼ CardStackView / CardView │ DragGesture / onTapGesture ▼ GameViewModel │ rules.canMove() → validation │ state.pushHistory() → undo stack │ state mutation → @Observable triggers SwiftUI diff │ soundManager.play() → audio feedback │ rules.isWon() → phase transition │ scheduleAutoSave() → debounced SwiftData write ▼ GameBoardView re-renders only changed cards ``` ## Persistence Model Three SwiftData `@Model` types: | Model | Purpose | Lifecycle | |-------|---------|-----------| | `GameRecord` | Saved game state (JSON-encoded `GameSnapshot`) | One per variant, deleted on win | | `StatsRecord` | Win/loss/streak per variant+difficulty | Permanent, updated on win/loss | | `PrefsRecord` | Theme, card style, sound toggle | Singleton, updated on change | ## File Organization Files are organized by architectural layer, not by feature: - **Models/** — pure data types, no dependencies - **GameEngine/** — game logic, depends only on Models - **ViewModels/** — UI state management, depends on GameEngine + Services - **Views/** — SwiftUI views, depends on ViewModels - **Services/** — sound, haptics, timer (side effects) - **Persistence/** — SwiftData models + manager - **Theme/** — color definitions and theme state This layering ensures that GameEngine can be tested without any UI or persistence dependencies.