True Count
Getting Started

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:

LayerRole
SwiftUI ViewsDisplay game state, capture player input, provide accessibility
ViewModelsOrchestrate game flow, bridge SwiftUI and SpriteKit, manage UI state
Game EnginePure blackjack rules with no UI or framework dependencies
Service LayerWrap 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

PrincipleTrue Count Example
Single ResponsibilityBlackjackRules 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/ClosedDealer 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 SubstitutionTests 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 SegregationAudioPlaying 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 InversionGameViewModel references GameCenterServiceProtocol, not GameCenterService. Concrete services are injected at the app's composition root. ViewModels never instantiate their own dependencies.

Code Reuse Patterns

PatternTrue Count Usage
Protocol extensionsDefault start()/stop() lifecycle implementations shared across service protocols
Pure functionsBlackjackRules.handValue(cards:) and BlackjackRules.availableActions(hand:dealerUpCard:) are stateless, deterministic, and the primary unit test targets
Composition over inheritanceGameViewModel holds GameCenterService + HapticsService + AudioService as properties. No BaseViewModel superclass.
Shared utilitiesCentralized 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

PatternWhen to UseTrue 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 injectionViewModels 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, ShoeSwiftUI Views, SpriteKit Scene
Hand, TableRules, DealerTableViewModels, Services
BlackjackRules, Strategy engineSwiftData models, CloudKit sync
Game state machine, Hi-Lo counterComposition 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?

On this page