Technical Overview
iOS frameworks, SpriteKit integration, GameplayKit state machine, and concurrency model for True Count.
True Count is a native iOS app built entirely with Apple frameworks. This page documents the technology stack, architecture decisions, and platform requirements for the development team.
At a Glance
| Specification | Value |
|---|---|
| Platform | iOS only (iPhone) |
| Minimum iOS Version | iOS 18 |
| Language | Swift 6.2 |
| Architecture | MVVM with @Observable |
| UI Framework | SwiftUI + SpriteKit (hybrid) |
| Game Center | Optional for core gameplay, required for leaderboard features (GameKit) |
| Persistence | SwiftData + CloudKit |
| In-App Purchases | StoreKit 2 |
Platform and iOS Version
True Count targets iOS 18 as the minimum deployment target. iOS 18 combined with iOS 26 covers approximately 95% of active iPhones (per TelemetryDeck, January 2026), which is more than sufficient for a premium app.
Apple renamed iOS at WWDC 2025, jumping from iOS 18 directly to iOS 26 to align version numbers across all Apple platforms. There is no iOS 19 through iOS 25.
Starting April 28, 2026, all new App Store submissions must be built with the iOS 26 SDK (Xcode 26). True Count builds with the latest SDK and deploys down to iOS 18. No iPad or macOS targets are planned for the initial release.
Liquid Glass
iOS 26 introduces a new design language called Liquid Glass. Standard SwiftUI components receive Liquid Glass styling automatically when built with Xcode 26. For iOS 18 users, standard material design renders instead. Use if #available(iOS 26, *) only for Liquid Glass-specific customizations.
App Architecture
Design Principles
For the design principles behind these decisions (MVVM rationale, SOLID, dependency injection, code reuse), see Architecture Principles.
True Count uses MVVM with @Observable as its governing architecture pattern. The @Observable macro (iOS 17+) provides property-level tracking, meaning SwiftUI only re-renders views that read the specific property that changed. Combined with @MainActor isolation (automatic in Swift 6.2 app targets), ViewModels are thread-safe by default and testable in isolation.
Architecture Layers
truecount (App Target)
SwiftUI Views ── menus, settings, stats, store, HUD overlays
SpriteKit Scene ── card/chip animations, particle effects, table felt
ViewModels ── GameViewModel, StoreVM, StatsVM, SettingsVM
Service Layer ── GameCenterService, StoreKitService, HapticsService, AudioService
Persistence ── SwiftData models with CloudKit sync
TrueCountEngine (Local Swift Package)
Game Engine ── BlackjackRules, Deck/Hand/Card models, state machine| Layer | Target | Responsibility |
|---|---|---|
| SwiftUI Views | App | Layout, display, user interaction, accessibility |
| SpriteKit Scene | App | Card and chip animations, particle effects, table visuals |
| ViewModels | App | Orchestrate game flow, bridge SwiftUI and SpriteKit |
| Game Engine | TrueCountEngine | Pure blackjack rules with no UI dependencies (compiler-enforced) |
| Service Layer | App | Wrap Apple frameworks behind protocols for testability |
| Persistence | App | SwiftData models with CloudKit for cross-device sync |
The Game Engine lives in a local Swift Package called TrueCountEngine. The package cannot import UIKit, SwiftUI, or any app-level framework. The app target imports TrueCountEngine as a dependency. See Architecture Principles for the design rationale.
Services are injected using SwiftUI's .environment() for @Observable classes that many views share (SoundManager, HapticEngine). Services requiring mock implementations for testing (GameCenterService, StoreKitService) use @Entry custom EnvironmentKeys with protocol abstractions.
Hybrid Rendering: SwiftUI + SpriteKit
True Count uses a hybrid rendering approach. SpriteKit handles the game table. SwiftUI handles everything around it.
SpriteKit handles the game table scene: card dealing, flipping, and sliding animations via SKAction sequences. Chip stacking with positional animations. Particle effects via SKEmitterNode for win celebrations and card sparkle. Table felt background with dynamic elements. SpriteKit's scene graph provides z-ordering and hit testing purpose-built for layered sprite management.
SwiftUI handles the app shell: main menu, settings, statistics (SwiftUI Charts), Game Center dashboard, store, and HUD overlays. SwiftUI provides native accessibility, Dynamic Type, Liquid Glass on iOS 26, and declarative layout for all non-game screens.
SpriteView bridges them. SpriteView (available since iOS 14) is Apple's official integration point for embedding an SKScene in a SwiftUI view hierarchy.
App Shell (SwiftUI)
├── Main Menu
├── Settings
├── Statistics (SwiftUI Charts)
├── Game Center Dashboard (GameKit)
├── Store (StoreKit 2)
└── Game Table Screen
└── SpriteView(scene: BlackjackTableScene)
├── Table felt background
├── Card nodes (deal/flip/slide SKActions)
├── Chip stack nodes
├── Particle emitters (sparkle, celebration)
└── HUD overlay (SwiftUI via ZStack)SwiftUI and SpriteKit Communication
The GameViewModel is a shared @Observable class referenced by both SwiftUI views and the SpriteKit scene. SwiftUI reads it through automatic @Observable tracking. The SpriteKit scene reads pending actions in its update(_:) loop. Both run on the main thread.
| Direction | Mechanism |
|---|---|
| SwiftUI to SpriteKit | Set pendingAction on the ViewModel, then unpause the scene |
| SpriteKit to SwiftUI | Scene writes state updates (hand result, bankroll) to the ViewModel |
| Shared state | Both read the same ViewModel properties (balance, hand cards, game phase) |
Game action buttons (Hit, Stand, Double, Split) live in SwiftUI, overlaid on the SpriteKit scene via a ZStack. This provides automatic VoiceOver support, Dynamic Type, and Liquid Glass styling that SpriteKit nodes would not have.
Pause and unpause lifecycle: SpriteKit is paused during idle periods for battery savings (see Performance Notes). When the user taps an action button, the SwiftUI handler sets pendingAction on the ViewModel and unpauses the scene. The scene reads and clears the pending action on the next frame, executes the animation, and re-pauses when the animation completes. Action buttons are disabled during animations (bound to viewModel.isAnimating) to prevent conflicting inputs.
The BlackjackTableScene instance is retained in a @State property to prevent SwiftUI from recreating the scene during view redraws.
Apple Frameworks
Rendering and Animation
| Framework | Purpose |
|---|---|
| SpriteKit | Game table rendering, card and chip animations, particle effects |
| SwiftUI | App shell, menus, settings, HUD overlays, accessibility |
Game Logic
| Framework | Purpose |
|---|---|
| GameplayKit | Card shuffling via GKMersenneTwisterRandomSource, game state machine via GKStateMachine |
Audio and Haptics
| Framework | Purpose |
|---|---|
| AVFoundation | Sound effects (card snap, chip clink, win jingle). Audio session set to .ambient to mix with the player's music. |
| CoreHaptics | Custom haptic patterns for gameplay events. Engine kept alive with isAutoShutdownEnabled = true to avoid per-event creation latency. |
Social and Monetization
| Framework | Purpose |
|---|---|
| GameKit | Game Center for leaderboards, achievements, and player identity. No multiplayer in the initial release. |
| StoreKit 2 | In-app purchases for credit packs. Async/await API with Product.products(for:) and Transaction.updates. |
Data and Sync
| Framework | Purpose |
|---|---|
| SwiftData | Local persistence for game history, statistics, and settings |
| CloudKit (via SwiftData) | Cross-device sync. Automatic when iCloud capability is configured. |
SwiftData with CloudKit
When CloudKit sync is enabled, all model relationships must be optional, @Attribute(.unique) constraints are not allowed, all properties need default values, and schemas are additive-only after production deployment. Plan the data model carefully before the first release.
Card Shuffling
True Count uses GameplayKit's GKMersenneTwisterRandomSource with arrayByShufflingObjects(in:) to perform a Fisher-Yates shuffle. This source supports deterministic seeding, which enables replay, debugging, and fairness auditing of specific shuffles. Deterministic seeding is the primary reason to use GameplayKit over Swift's built-in shuffle(), which cannot be seeded.
The app defines a Shoe struct that tracks remaining cards, supports configurable deck counts (2, 4, 6, and 8 as defined in Dealer Tables), and stores the random source so seeded replays produce identical shuffles. See Shuffling and Deck Penetration for the gameplay rules governing reshuffles and penetration depths.
Game State Machine
GKStateMachine manages the round flow. Each round progresses through a defined set of states with enforced transition rules.
| State | Description | Valid Next States |
|---|---|---|
| Betting | Player places a bet | Dealing |
| Dealing | Cards dealt to player and dealer | Insurance, PlayerTurn, Evaluating |
| Insurance | Offered when the dealer shows an Ace | PlayerTurn, Evaluating |
| PlayerTurn | Hit, Stand, Double Down, Split for the active hand | PlayerTurn (next split hand), DealerTurn, Evaluating |
| DealerTurn | Dealer plays according to table rules | Evaluating |
| Evaluating | Compare hands and determine winners | Payout |
| Payout | Animate chips, update balance and statistics | RoundComplete |
| RoundComplete | Reset the table for the next round | Betting |
isValidNextState enforces legal transitions so the game cannot skip from Betting to Payout. didEnter(from:) triggers the corresponding SpriteKit animation (dealing cards, flipping the dealer hole card, sweeping chips).
Natural blackjack: Naturals are resolved from the opening deal flow. The dealer peeks for blackjack when showing an Ace or 10-value upcard. If insurance is applicable (Ace upcard with insurance enabled), the Insurance branch runs first. If a natural outcome then resolves the hand, the flow skips PlayerTurn and DealerTurn and goes directly to Evaluating.
Split hands: When a player splits, PlayerTurn tracks which hand is active via an index. Each split hand plays in sequence before transitioning to DealerTurn. Split aces receive exactly one card each and auto-stand. This is handled as logic within the PlayerTurn state, not as a separate state.
A pure BlackjackRules struct provides static functions for hand value calculation, dealer hit decisions, and available action determination. This layer contains no UI or framework dependencies and is the primary unit test target.
Haptic Design
| Event | Haptic Feel |
|---|---|
| Card deal | Light tap |
| Card flip | Crisp snap |
| Chip placement | Soft thud |
| Blackjack or win | Celebratory ascending pattern |
| Bust | Heavy single thud |
True Count offers a haptic feedback toggle: On or Off. See Haptic Feedback for the player-facing settings.
Concurrency
Swift 6.2 (shipping with Xcode 26) defaults app targets to @MainActor isolation. This is ideal for a game where most state mutations happen in response to user interaction on the main thread.
| Isolation | Components |
|---|---|
| @MainActor (default) | SwiftUI Views, ViewModels, SpriteKit Scene, HapticsService, AudioService |
| ModelActor | SwiftData persistence (writes on a dedicated context) |
SpriteKit types (SKScene, SKNode) and GameplayKit types (GKStateMachine, GKRandomSource) are not Sendable and must remain on @MainActor. SwiftData model objects (PersistentModel) are also not Sendable. When passing data between @MainActor ViewModels and a ModelActor, pass PersistentIdentifier values and re-fetch on the receiving actor's context.
Error Handling
| Subsystem | Failure Mode | Handling |
|---|---|---|
| StoreKit 2 | Purchase failure, network error, cancellation | Transactions are journaled locally and resolve when connectivity returns. UI communicates purchase status. |
| GameKit | Authentication failure, network error | The app is fully playable without Game Center. Leaderboard submissions queue and retry on reconnection. |
| CloudKit | Sync failure, quota limit, schema mismatch | SwiftData works offline by default (local SQLite). Sync resumes automatically on reconnection. |
| SwiftData | Disk full, migration failure | Lightweight migration only (additive changes). Errors surfaced via a centralized error banner. |
The app must be fully playable offline. All network-dependent features (leaderboard submission, CloudKit sync, StoreKit verification) queue automatically and resolve when connectivity returns.
CloudKit uses last-writer-wins for conflict resolution. The Game Center page documents the "one device at a time" constraint. The app detects concurrent sessions and warns the player to avoid sync conflicts.
Performance Notes
Battery optimization is the highest-impact concern. SpriteKit renders at 60fps continuously, even when the card table is static. Players spend approximately 80% of game time thinking with no animations playing. Pause the SpriteKit scene during idle periods (scene.isPaused and skView.isPaused both set to true). Resume when animations start. This saves 60 to 70% of GPU power draw during a session.
Launch strategy: Defer GameKit authentication and StoreKit product fetching until the player first accesses those features. Preload sound effects and start the CoreHaptics engine after the first frame appears. Target under 400ms to the first meaningful frame and under 1 second to a fully interactive game table.
Texture management: Pack all 52 card faces and card backs into a single 2048x2048 texture atlas. This keeps draw calls to 2 to 4 per frame and texture memory predictable.
Data at scale: Index date fields in SwiftData models used in predicates. Use FetchDescriptor with fetchLimit for pagination. Precompute aggregate statistics in a summary model rather than recalculating from raw hand records on every view appearance.
Accessibility
SpriteKit has no built-in accessibility support. True Count bridges this gap with a layered approach.
Game action controls (Hit, Stand, Double Down, Split) live in SwiftUI overlays with automatic VoiceOver support, Dynamic Type, and standard accessibility traits. Custom UIAccessibilityElement objects are created for card positions and chip stacks on the SpriteKit table, with accessibilityValue updated as the game state changes (for example, "Player hand: Ace of Spades, 10 of Hearts. Total: 21.").
SKLabelNode does not respect Dynamic Type. Any text rendered in SpriteKit reads the preferred font size from the system and scales manually.
How is this guide?