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

5 KiB
Raw Permalink Blame History

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.