Loading repository data…
Loading repository data…
brillcp / repository
A simple Pokedex app written in SwiftUI that implements the PokeAPI, using Swift Concurrency, MVVM architecture and pagination
PocketDex+ is a SwiftUI app built on top of the PokeAPI, with a working turn-based Pokemon battle mode driven by Apple's on-device FoundationModels framework and local multiplayer over MultipeerConnectivity. Browse the dex, dig into a pokemon, pick a fight against AI or a friend nearby.
If you're a senior iOS engineer looking for a worked example of modern SwiftUI patterns (actors, @Observable, SwiftData, on-device AI, MultipeerConnectivity), or someone earlier in their iOS journey trying to see how these pieces fit together in a real app, hopefully there's something here for you. Every feature is small enough to read end-to-end, and every public type plus every protocol member carries a doc comment explaining why it exists.
Built by Viktor Gidlöf.
PocketDex+ is Protocol-Oriented MVVM with clear layer boundaries and aggressive actor isolation.
APIService<Config> actor over a Requestable protocol drives every PokeAPI endpoint.@MainActor @Observable.@Attribute(.unique) on every keyed cache entity (Pokemon by id, EvolutionChainEntity by chainId). Nested rows ride on cascade; ItemData is keyed by category title. Move and type data live in PokeBattleKit's own disk cache.@Observable view models.FoundationModels with @Generable structured output, Tool-based type/damage reasoning, and deterministic fallbacks via PokeBattleKit at every call site.Codable message protocol over MultipeerConnectivity. The same BattleViewModelProtocol drives both AI and peer-to-peer battles with zero view changes.APIService<Config> generic + Requestable protocol lets new endpoints slot in without modifying the network layer. BattleViewModelProtocol let an entirely new battle mode ship without touching the battle view, animator, or log formatter.AppContainer, so previews and tests can swap any concrete type for a mock without touching call sites. Multiple conformers of the same protocol are interchangeable at runtime.AppContainer is the single composition root. Views read services via @Environment(\.container). No static let shared lookups in feature code.┌─────────────────────────────────────────────────────────┐
│ App/ │
│ PokedexUIApp AppContainer (composition root) │
├─────────────────────────────────────────────────────────┤
│ Features/ │
│ Pokedex PokemonDetail Battle Search │
│ Bookmarks Items Multiplayer │
├─────────────────────────────────────────────────────────┤
│ Core/ │
│ Domain/ (SwiftData @Models) │
│ Services/ (actor-backed networking and prefetchers) │
│ Storage/ (DataStorageReader @ModelActor) │
│ Networking/ (APIService<Config> generic actor) │
├─────────────────────────────────────────────────────────┤
│ DesignSystem/ │
│ Components/ Colors/ Modifiers/ Fonts │
└─────────────────────────────────────────────────────────┘
Every long-lived worker is an actor unless it has to bind to SwiftUI directly:
| Type | Isolation | Why |
|---|---|---|
BattleAIService | actor | Owns the LanguageModelClient, called from any context |
SpriteLoader | actor | Image download + URLCache access |
ImageColorAnalyzer | actor | Pixel-scan pipeline, off the main thread |
AudioPlayer | actor | AVFoundation playback |
EvolutionService | actor | Process-wide chain-id memo |
DataStorageReader | @ModelActor | Isolated SwiftData ModelContext |
APIService<Config> | actor | Generic network actor over Requestable |
MultipeerService | @Observable | MC delegate callbacks are nonisolated, hop to @MainActor inline only when mutating observable state |
BattleAnimator | @MainActor @Observable | Owns cue mutation + withAnimation blocks for the arena |
| View models | @MainActor @Observable | SwiftUI binding |
The type chart lives in PokeBattleKit as a Sendable value type (TypeChart), initialized once at app launch via PokeBattleKit.initialize(). Views and AI read it through PokeBattleKit.typeChart with zero actor hops. BattleEngine is also a plain Sendable struct in PokeBattleKit, used on the main actor inside BattleViewModel but not isolated itself.
A single AppContainer is the composition root. Every service, prefetcher, and long-lived worker is constructed there and handed to the view tree through one environment key:
@MainActor
final class AppContainer {
let pokemonService: PokemonServiceProtocol
let evolutionService: EvolutionServiceProtocol
let itemService: ItemServiceProtocol
let spriteLoader: SpriteLoading
let imageColorAnalyzer: ImageColorAnalyzing
let audioPlayer: AudioPlaying
let battleAI: BattleAIServiceProtocol
let multipeerService: MultipeerService
static let live = AppContainer()
}
@Environment(\.container) private var container
Tests and previews swap in a custom container with mocks; the rest of the app is unaware.
Three top-level @Model types, each deduped on a unique key. Nested rows (stats, abilities, sprites, etc.) live under their parent and ride on cascade delete.
Pokemon (id-unique): full hydrated detail, stats, sprites, moves, species fields, bookmark flagItemData (title-keyed): item catalogue bucketed by category titleEvolutionChainEntity (chainId-unique): evolution chain rows keyed by chain idMove and type effectiveness data are owned by PokeBattleKit, which caches them as JSON files on disk via its own DiskCache. The pokedex grid renders from Pokemon rows; full hydration runs once at app launch and is cached forever, since Pokemon data is immutable.
The battle screen is a turn-based 1v1 simulator built on top of the real PokeAPI move and type data. Both sides commit to 4 hand-picked moves before the fight starts, then trade turns until one side faints. The Gen-V damage formula drives every hit (level 50, STAB, type effectiveness, crit roll, accuracy roll, burn penalty), and status effects (paralysis, burn, poison) tick at end-of-turn.
The opponent is driven entirely by Apple's FoundationModels framework, running fully on-device.
PocketDex+ uses SystemLanguageModel.default with structured generation (@Generable) and tool use (Tool protocol) for three decisions:
OpponentPickResult with the chosen index.checkTypeEffectiveness and estimateDamage tools to reason about coverage before returning a LoadoutPickResult with four move names.estimateDamage to compare options and detect KOs, then returns a MovePickResult with the chosen move name.Every call degrades gracefully to deterministic heuristics (in PokeBattleKit) if Apple Intelligence is unavailable, the session is busy, or the model returns an unresolvable result. The battle UI never blocks waiting on a model response.
Each AI decision uses @Generable structs for type-safe output instead of free-text parsing. The model returns typed results (MovePickResult, LoadoutPickResult, OpponentPickResult) that resolve directly to game objects by name or index lookup.
Two Tool conformances give the model access to game data it can't derive from training:
CheckTypeTool: wraps the type chart to report effectiveness multipliers for any attacking type vs defending types.EstimateDamageTool: wraps the Gen-V damage calculator to return estimated damage, defender HP, and whether the hit would KO.System instructions live in PokedexUI/Features/Battle/AI/LLMInstructions/, split per task. Each is a single short paragraph guiding tool usage and tactical priorities.
The scoring, filtering, and post-pick correction logic lives in PokeBattleKit as pure deterministic code with no LLM dependency:
MoveScoring: in-battle and loadout scoring with STAB, type effectiveness, priority, and escalating recency penalties to prevent move spamming.MoveStrategy: heuristic fallback pick and post-pick adjustments (immune repair, phase-aware switching, KO override, s