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>
103 lines
5 KiB
Markdown
103 lines
5 KiB
Markdown
# 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.
|