“Where’s The Dot” is a casual iOS memory game built with Swift. Players must remember and tap the newest dot added to the screen each round. A wrong tap ends the game. Score equals the number of dots successfully identified.
Clean Architecture with strict layer separation:
WheresTheDot/
├── Domain/ # Entities, protocols, core business rules
│ └── Protocols/ # DotLayoutGenerating, GameSessionRepository, LevelProgression, ThemeRepository, …
├── Data/ # Concrete implementations (repositories, adapters)
├── Use Cases/ # Orchestrators between domain and presentation
├── Presentation/ # SwiftUI views, coordinators, state
│ ├── AnimatedDots/
│ ├── Components/
│ ├── Routes/
│ └── View Models/
├── Tools/ # Cross-cutting concerns (audio, haptics, analytics, config)
├── Utils/ # Extensions
├── GameScene.swift # SpriteKit scene (rendering + touch input)
└── WheresTheDot.swift # @main entry point + AppDelegate
Dot, Round, GameSnapshot, Difficulty, Theme), enums (GameMode, RoundOutcome, ThemeID), and protocols (DotLayoutGenerating, GameSessionRepository, LevelProgression, ThemeRepository, RandomNumberGenerating).InMemoryGameSessionRepository, GKRandomAdapter (wraps GameplayKit), DotLayoutGenerator, SimpleArcadeProgression, UserDefaultsThemeRepository.StartGameUseCase, AddDotIfCorrectUseCase, CheckThemeUnlocksUseCase). Call with useCase(args) not useCase.execute(args).AppState (navigation + settings + theme), GameCoordinator (game flow), AppContainer (DI root).AudioManager (singleton, background music), Haptics (enum with static methods), FirebaseEventsManager (analytics), RemoteConfigManager (remote config + local overrides), GameCenterManager (leaderboards + achievements), StoreKitManager (IAP, singleton), AdsManager (AdMob interstitials), AdminConfig (dev flag).struct StartGameUseCase {
func callAsFunction(in area: CGRect) -> Round { ... }
}
// Used as: startGame(in: area)
@MainActor final class AppContainer: ObservableObject {
let startGame: StartGameUseCase
let addDotIfCorrect: AddDotIfCorrectUseCase
}
// Provided via .environmentObject(container) at root
@MainActor final class GameCoordinator: ObservableObject {
@Published var roundIndex: Int
@Published var score: Int
// Owns game flow; drives SpriteKit scene via closures
}
GameScene is embedded in SwiftUI via SpriteViewonDotTapped, onSceneReady, onTapFeedbackSpriteViewscene.themeColors, scene.themeGridColor, scene.themeBackgroundColor, scene.themeDotShapethemeDotShape to .randomAssets auto-resolves it to a specific .asset on first renderProtocol names use the -ing suffix: DotLayoutGenerating, RandomNumberGenerating, LevelProgression.
| Category | Convention | Example |
|---|---|---|
| Types, structs, classes | PascalCase | GameSnapshot, AppContainer |
| Protocols | PascalCase + -ing |
DotLayoutGenerating |
| Enums | PascalCase | GameMode, RoundOutcome |
| Files — Views | *View.swift |
MainMenuView.swift |
| Files — Use Cases | *UseCase.swift |
StartGameUseCase.swift |
| Files — Repositories | *Repository.swift |
InMemoryGameSessionRepository.swift |
| Files — Adapters | *Adapter.swift |
GKRandomAdapter.swift |
| Files — Styles | *Style.swift |
DottoButtonStyle.swift |
| Files — Extensions | Type+Extensions.swift |
Color+Extensions.swift |
GKARC4RandomSource (wrapped behind RandomNumberGenerating)UIImpactFeedbackGenerator), UIColor color definitionsGameCenterManagerStoreKitManagerAdsManager (guarded by #if canImport(GoogleMobileAds))FirebaseEventsManagerRemoteConfigManagerarcade_difficulty_step). Radius shrinks, timing tightens, dot size variation increases.Themes are defined in Domain/Theme.swift. There are two unlock mechanisms:
Score-unlocked themes (cumulative lifetime score milestones):
| Theme | Unlock | Background | Grid | Dot Shape |
|---|---|---|---|---|
| Neon | Free | #05060A |
Cyan | circle |
| Forest | 50 pts | #050D07 |
Green | randomAssets (forest_dot, 5 variants) |
| Ocean | 200 pts | #03080F |
Cyan-blue | randomAssets (ocean_dot, 5 variants) |
| Cosmos | 300 pts | #080510 |
Purple | randomAssets (cosmos_dot, 5 variants) |
Premium IAP-unlocked themes (purchase via StoreKitManager):
| Theme | Product ID | Background | Grid | Dot Shape |
|---|---|---|---|---|
| Aurora | …theme.aurora |
dark blue | #BAE6FD |
randomAssets (aurora_dot, 5 variants) |
| Inferno | …theme.inferno |
dark red | #F97316 |
asset (flame.fill) |
| DoctorPing | …premium |
dark blue | #64B5D9 |
asset (DoctorPing) |
| Space Travel | …premium |
dark teal | #143d4a |
asset (star.fill) |
enum DotShape: Equatable {
case circle
case asset(named: String, fallbackSymbol: String)
case randomAssets(prefix: String, count: Int, fallbackSymbol: String)
}
.circle — rendered as SKShapeNode with additive-blend glow halo.asset(named:fallbackSymbol:) — tries xcassets PNG first, falls back to SF Symbol tinted as texture.randomAssets(prefix:count:fallbackSymbol:) — resolved once to a specific .asset when assigned to GameScene.themeDotShapeThemeID is a String-rawValue CaseIterable enum — safe for @AppStorage and Codable.Theme.name is LocalizedStringResource so Text(theme.name) auto-localizes.AppState.currentTheme is the single source of truth for the active theme.AppState.isUnlocked(theme:) checks score milestones for free themes and StoreKit entitlements for premium themes.CheckThemeUnlocksUseCase records cumulative score and returns newly unlocked score-based themes on game over.theme_forest_milestone, etc.).RemoteConfigManager.shared.milestone(for:) returns nil for premium themes (.aurora, .inferno, .doctorping, .spacetravel).theme.unlockScore for unlock logic — use RemoteConfigManager.shared.milestone(for:).StoreKitManager.shared.isPurchased(_:) or AppState.isUnlocked(theme:).All events go through FirebaseEventsManager (static methods only). GameOverReason enum provides typed reason parameters (wrongTap / timeUp).
Key events: select_game_mode, game_over, game_ended, game_quit, round_correct, open_themes, select_theme, theme_unlocked, unlock_theme, open_store, purchase_initiated, purchase_completed, onboarding_started, onboarding_intro_dismissed, onboarding_completed, onboarding_skipped, leaderboard_opened, achievements_opened, open_settings, sound_enabled/disabled, haptics_enabled/disabled, color_blind_mode_enabled/disabled.
RemoteConfigManager.shared is the single access point. It has a local override layer (backed by UserDefaults with rc.override. prefix) used by the admin panel.
Always read values from RemoteConfigManager.shared — never hardcode the following:
| Key | Controls |
|---|---|
arcade_time_limit_base |
Base seconds for arcade timer at level 1 |
arcade_difficulty_step |
Points between difficulty levels |
memory_cover_duration |
Cover duration between rounds |
onboarding_enabled |
Kill switch for onboarding flow |
arcade_mode_enabled |
Feature flag for arcade mode |
default_theme |
Theme for first-time users |
theme_forest_milestone |
Forest unlock threshold |
theme_ocean_milestone |
Ocean unlock threshold |
theme_cosmos_milestone |
Cosmos unlock threshold |
Offline defaults live in Supporting Files/remote_config_defaults.plist.
StoreKitManager.shared manages all IAP. Product IDs (bundle: com.optionalsankur.Dotto):
| Product | ID suffix | Type |
|---|---|---|
| Remove Ads | removeads |
Non-consumable |
| Aurora Theme | theme.aurora |
Non-consumable |
| Inferno Theme | theme.inferno |
Non-consumable |
| Premium Bundle (all) | premium |
Non-consumable |
StoreKitManager.shared.isAdFree — true if removeads or premium is purchasedStoreKitManager.shared.isPurchased(_:) — check entitlement for any product IDpremium bundleAdsManager.shared shows interstitial ads every 3rd game-over, guarded by StoreKitManager.shared.isAdFree. Integration requires:
https://github.com/googleads/swift-package-manager-google-mobile-ads.gitGADApplicationIdentifier key in Info.plistAdsManager.swiftCall AdsManager.shared.recordGameOver() on both wrong-tap and time-up game-over paths.
GameCenterManager handles leaderboards and achievements. There are 12 achievements:
AdminConfig.isEnabled (default false) is a compile-time flag in Tools/AdminConfig.swift. When true, a wrench button appears in the main menu footer, routing to AdminView via .admin in AppRoute. AdminView includes gameplay tuning, feature flag overrides, theme milestone overrides, IAP simulation (Premium status), and ad counter controls. Never ship with isEnabled = true.
Color palette (Color+Extensions.swift):
#05060A (Neon theme black), per-theme dark tints for othersVisual conventions:
NeonGridBackground(color:backgroundColor:) for menu/settings backgrounds — always pass appState.currentTheme.gridColor and appState.currentTheme.backgroundColorDottoButtonStyle for all primary buttons — gradient capsule with glossy overlaySupporting Files/Localizable.xcstringsLocalizedStringResource so Text(theme.name) localizes automaticallyString literals to user-facing Text() calls without a corresponding xcstrings entry@MainActor on AppState, AppContainer, GameCoordinator, AudioManager, HapticsRemoteConfigManager and FirebaseEventsManager are NOT actor-isolated — safe to call from anywhereTask { @MainActor in ... } when bridging from non-actor contexts@Test, #expect)WheresTheDotTests/WheresTheDotUITests/ (currently stubs)AppRoute enum drives all navigation via RootView:
.mainMenu.game(GameMode) — GameMode: .classic, .arcade, .daily(seed: UInt64).settings.themes.store — PurchaseView premium purchase sheet.admin — only reachable when AdminConfig.isEnabled == trueAppRoute enum + RootViewGameScene; keep it as a pure rendering/input layerUserDefaults directly; go through a repository protocoltheme.unlockScore for unlock checks — use RemoteConfigManager.shared.milestone(for:)NeonGridBackground — use the active theme’s colorsStoreKitManager.shared.isPurchased(_:) or AppState.isUnlocked(theme:)StoreKitManager.shared.isAdFree firstStoreKitManager