SoliCards-iOS-iPadOS-MacOS/ARCHITECTURE.md
idev2025 de0da01f25 feat: SoliCards v1.2.0 — native SwiftUI solitaire for iOS, iPadOS, macOS
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.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:26:14 -04:00

103 lines
5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.