Complete native rewrite of the web-based SoliCards game as a SwiftUI multiplatform app targeting iOS 17+, iPadOS 17+, and macOS 14+. Three solitaire variants (Klondike, Spider, FreeCell) with full game rules, drag & drop, smart zoom layout, 6 themes, 4 difficulty levels, SwiftData persistence, VoiceOver accessibility, and 57 unit tests. Key features: - MVVM + Protocol-Oriented Strategy architecture - DragGesture with coordinate-space hit-testing (long press + drag) - Smart zoom: cards auto-size to fit screen based on deepest column - Landscape: 30% bigger cards with scrollable overflow (iOS) - macOS: 120pt card cap, 92% height buffer for window resizing - Auto-save, game resume, statistics tracking via SwiftData - Privacy manifest, app icon, String Catalog, zero dependencies Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5 KiB
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:
DragGesturegives direct control over positioning and animationDropTargetPreferenceKeyreports frame geometry for hit-testingDraggedCardsOverlayrenders 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
@Publishedboilerplate - Works with
@Bindablefor 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.