Architecture Principles
MVVM rationale, SOLID principles, dependency injection, and code reuse strategies for True Count.
This page documents the design principles governing True Count's codebase. For specific frameworks, architecture layers, and implementation details, see Technical Overview.
Why MVVM
True Count uses MVVM as its governing architecture pattern. After evaluating TCA, VIPER, MVC, and Entity-Component-System, MVVM was chosen for its proportional complexity.
TCA introduces a competing state machine alongside GKStateMachine, duplicating flow control. VIPER requires five files per screen for a game that has only a handful of screens. MVC has no controller concept in SwiftUI, so the pattern doesn't map cleanly. ECS is designed for hundreds of diverse entities, not a card game with approximately 15 objects.
MVVM fits because True Count has a clear separation between what the player sees and what the game logic decides:
| Layer | Role |
|---|---|
| SwiftUI Views | Display game state, capture player input, provide accessibility |
| ViewModels | Orchestrate game flow, bridge SwiftUI and SpriteKit, manage UI state |
| Game Engine | Pure blackjack rules with no UI or framework dependencies |
| Service Layer | Wrap Apple frameworks behind protocols for testability |
Each ViewModel manages state for one feature area (GameViewModel, StoreVM, StatsVM, SettingsVM). If describing a ViewModel requires "and" more than once ("fetches AND validates AND persists AND animates"), it has too many responsibilities and should be split.
API Details
The @Observable macro, @MainActor isolation, and property-level tracking that make MVVM work in SwiftUI are documented in Technical Overview.
SOLID Principles
| Principle | True Count Example |
|---|---|
| Single Responsibility | BlackjackRules contains only rule logic: hand values, dealer decisions, available actions. GameViewModel orchestrates game flow. GameCenterService wraps GameKit. HapticsService wraps CoreHaptics. AudioService wraps AVFoundation. One class, one job. |
| Open/Closed | Dealer table configurations (deck count, double restriction, blackjack payout) are data, not code branches. BlackjackRules reads the configuration without if/switch chains per table type. New table rules don't require modifying BlackjackRules. |
| Liskov Substitution | Tests use MockGameCenterService. Production uses GameCenterService. Both conform to GameCenterServiceProtocol and behave identically for leaderboard submissions, achievement unlocks, and authentication checks. Swapping one for the other changes nothing for callers. |
| Interface Segregation | AudioPlaying and HapticPlaying are separate protocols. ViewModels reference only the protocols they need. GameViewModel depends on HapticPlaying but not AudioPlaying if it does not trigger sounds directly. |
| Dependency Inversion | GameViewModel references GameCenterServiceProtocol, not GameCenterService. Concrete services are injected at the app's composition root. ViewModels never instantiate their own dependencies. |
Code Reuse Patterns
| Pattern | True Count Usage |
|---|---|
| Protocol extensions | Default start()/stop() lifecycle implementations shared across service protocols |
| Pure functions | BlackjackRules.handValue(cards:) and BlackjackRules.availableActions(hand:dealerUpCard:) are stateless, deterministic, and the primary unit test targets |
| Composition over inheritance | GameViewModel holds GameCenterService + HapticsService + AudioService as properties. No BaseViewModel superclass. |
| Shared utilities | Centralized currency formatter for chip/bankroll display, card value mapping constants used by both the rules engine and the SpriteKit scene |
Rule of Three
If you find yourself writing similar logic in three or more places, extract it into a shared function, protocol extension, or utility.
Dependency Injection
| Pattern | When to Use | True Count Usage |
|---|---|---|
Environment injection (.environment() or @Entry) | Services consumed by SwiftUI views. Use protocol types for testability. | SoundManager and HapticEngine via .environment(). GameCenterServiceProtocol and StoreKitServiceProtocol via @Entry with protocol abstractions. |
| Constructor injection | ViewModels and pure Swift types with no SwiftUI dependency. Compile-time safety, explicit dependencies. | GameViewModel(rules: BlackjackRules, deck: Shoe). The Game Engine layer uses constructor injection exclusively. |
Environment Limitation
Environment values can only be accessed by SwiftUI views, not by ViewModels directly. ViewModels must use constructor injection.
Modularization
True Count separates game logic from the app using a local Swift Package called TrueCountEngine. This is a compile-time boundary, not a convention: the package physically cannot import UIKit, SwiftUI, or any app-level framework.
Package Boundary
truecount (app) ──imports──> TrueCountEngine (package)
TrueCountEngine CANNOT import app target| TrueCountEngine (package) | truecount (app target) |
|---|---|
| Card, Suit, Rank, Shoe | SwiftUI Views, SpriteKit Scene |
| Hand, TableRules, DealerTable | ViewModels, Services |
| BlackjackRules, Strategy engine | SwiftData models, CloudKit sync |
| Game state machine, Hi-Lo counter | Composition root, Haptics, Audio, GameCenter, StoreKit |
All Phase A slices (S01-S10) live in the package. All Phase B-E code (ViewModels, Services, SwiftData, UI) lives in the app target.
Access Control
The package uses public for all types, properties, and methods consumed by the app target. Swift does not synthesize public memberwise initializers, so every struct in the package has an explicit public init(...). Internal helpers use Swift's default internal access.
Benefits
- Enforced purity: compiler rejects UIKit/SwiftUI imports in the package
- Faster tests: engine tests run without building the app target (4-5x faster)
- Build isolation: Xcode only rebuilds the package when engine files change
- Team boundaries: engine and UI work cannot create merge conflicts in the same target
For the specific frameworks, architecture layers, and implementation details, see Technical Overview.
How is this guide?