PokedexUI 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.
PokedexUI is Protocol-Oriented MVVM with clear layer boundaries and aggressive actor isolation.
- ✅ Protocol-Oriented: every layer depends on abstractions, enabling DI and easy testing.
- ✅ Generic Networking: one
APIService<Config>actor over aRequestableprotocol drives every PokeAPI endpoint. - ✅ Storage-First: SwiftData is the source of truth; the network is a backfill mechanism.
- ✅ Actor-Based Concurrency: every long-lived worker is an actor; SwiftUI-bound types are
@MainActor @Observable. - ✅ Clean Separation: App / Features / Core / DesignSystem layers with one-way dependencies (App can see everything, Core depends on nothing).
- ✅ Type Safety: generics, Sendable AI snapshots crossing actor boundaries,
@Attribute(.unique)on every keyed cache entity (Pokemon by id, EvolutionChainEntity by chainId). Nested rows ride on cascade;ItemDatais keyed by category title. Move and type data live in PokeBattleKit's own disk cache. - ✅ Reactive UI: SwiftUI body re-renders driven entirely by
@Observableview models. - ✅ On-Device AI: Apple
FoundationModelswith@Generablestructured output,Tool-based type/damage reasoning, and deterministic fallbacks via PokeBattleKit at every call site. - ✅ Local Multiplayer: host-authoritative architecture with a typed
Codablemessage protocol over MultipeerConnectivity. The sameBattleViewModelProtocoldrives both AI and peer-to-peer battles with zero view changes.
- Single Responsibility: each service, prefetcher, and view model has one job. View models are thin conductors that delegate timing, formatting, and strategy to dedicated collaborators. Shared UI components each own a single reusable concern.
- Open/Closed: the
APIService<Config>generic +Requestableprotocol lets new endpoints slot in without modifying the network layer.BattleViewModelProtocollet an entirely new battle mode ship without touching the battle view, animator, or log formatter. - Liskov Substitution: every service is reached through its protocol on
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. - Interface Segregation: each view model exposes only the surface its view needs. No god-protocol shared across consumers.
- Dependency Inversion:
AppContaineris the single composition root. Views read services via@Environment(\.container). Nostatic let sharedlookups 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 containerTests 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 id
Move 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.
PokedexUI uses SystemLanguageModel.default with structured generation (@Generable) and tool use (Tool protocol) for three decisions:
- Opponent picking ("Random" button in the picker sheet)
The model receives the player's name, types, and BST plus a shuffled roster of up to 50 pre-filtered candidates with matchup annotations. It returns a structured
OpponentPickResultwith the chosen index. - Loadout selection (background task during the loadout screen)
The model receives both combatants' typings and a shortlisted move pool. It can call
checkTypeEffectivenessandestimateDamagetools to reason about coverage before returning aLoadoutPickResultwith four move names. - Per-turn move selection (every time the player commits a move)
The model receives both combatants' current HP, status, types, and the opponent's four moves. It can call
estimateDamageto compare options and detect KOs, then returns aMovePickResultwith 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, status redundancy).LoadoutStrategy: shortlisting, heuristic 4-move selection, fill (pad LLM partial picks to 4), and mild fairness handicapping.OpponentStrategy: BST-tolerant pool filtering, mutual-threat scoring, and heuristic best-opponent ranking.
Every BattleAIService method follows the same shape: PokeBattleKit computes a heuristic fallback, the LLM attempts a smarter pick via structured generation with tools, then PokeBattleKit runs post-pick corrections on whichever branch won.
All scoring weights live in MoveScoring.Weights inside PokeBattleKit. They're public static properties, so you can read (and fork) them to experiment with AI behavior:
| Weight group | What it controls | Examples |
|---|---|---|
| Damage | How much the AI values raw damage, KO potential, resisted hits | koBonus (55), nearKOBonus (18), resistedMult (0.4) |
| Status | Value of inflicting paralysis, burn, poison, sleep | paralysisFaster (28), burnPhysical (24), sleep (22) |
| Stat changes | Boost/debuff desirability based on matchup context | statBoostMatching (10), statBoostSpeedSlow (16) |
| Recency | Escalating penalties for repeating the same move | repeatFirst (18), repeatSecond (40), repeatThird (65) |
| Low HP | Survival instinct: healing and priority at low health | lowHPHealBonus (35), lowHPPriorityBonus (6) |
Raising koBonus makes the AI more aggressive. Lowering the recency penalties lets it spam its best move. Bumping lowHPHealBonus makes it play safer when hurt. The heuristic fallback and LLM post-pick adjustments both flow through these weights, so a single change affects both code paths.
- Zero latency to the network: every inference happens locally.
- Free: no API tokens, no rate limits.
- Private: battle state never leaves the device.
- Available offline: the app stays playable once the PokeAPI data is cached.
This is the kind of feature FoundationModels was built for: small, structured, latency-sensitive decisions on top of well-defined data.
Both players open the Gym tab, which advertises and browses simultaneously. Nearby trainers appear in a discovery list; tapping one sends an invite. Once both accept, each picks a Pokemon and four moves, then the battle begins. All communication is peer-to-peer over Wi-Fi or Bluetooth via MultipeerConnectivity. No servers, no accounts, no internet required.
Host-authoritative. The MC advertiser is the host; the browser is the guest. Only the host runs BattleEngine. The guest sends its move choice, receives resolved events, and renders them. This eliminates desync from random rolls (crits, accuracy, status chance).
A single Codable enum (BattleMessage) covers the full lifecycle:
| Phase | Messages | What crosses the wire |
|---|---|---|
| Handshake | .hello, .challengeProposed, .challengeAccepted, .challengeDeclined |
Protocol version, PokemonSummary (id, name, sprites, types, stats), 4 move names |
| Battle | .moveCommitted, .roundResolved, .battleEnded |
Move name + turn number, resolved [Event] array, winner side |
| Session | .rematch, .disconnect |
Control signals only |
Move names resolve locally via PokeBattleKit.move(named:). Full Move objects never cross the wire. Both devices share the same move database from PokeBattleKit.initialize().
Commit-collect-resolve pattern with no races:
- Both players see the move grid
- Player taps a move, sends
.moveCommitted(name, turnN)to peer - Host collects: stores own move + guest's move. When both present for turn N, runs
engine.resolveRound(), sends.roundResolved(events, N)to guest - Guest receives: applies events to local state, animates via
BattleAnimator - Both reset for turn N+1
The guest reverses .player/.opponent in received events via side.opposite before rendering. turnNumber on every message prevents stale/duplicate processing.
- Separate VM, not branching:
MultiplayerBattleViewModelconforms toBattleViewModelProtocolalongsideBattleViewModel.BattleViewrenders either without knowing which one it has. - Shared components:
PokemonPickerGrid,PokemonSpriteCard,MoveLoadoutView,MovePickerGrid, andMoveSelection(observable selection state class) are used by both single-player and multiplayer flows.BattleStateReducerhandles state mutation for both battle VMs. Screen-level orchestration stays separate. - Rematch reuses MC session: no need to re-discover. Just exchange new
ChallengePayloads.
Detail view
│ (tap Fight)
▼
Opponent picker sheet <-- Random (AI)
│ (tap a candidate)
▼
Loadout screen
│ - Hydrate both pokemon (cache or network)
│ - AI picks opponent's 4 from full movepool (background task)
│ - Player hand-picks own 4 from sorted movepool
│ (tap Battle)
▼
Battle view
- Arena renders frame 1 (state built in init)
- Each turn: player taps move ->
AI picks opponent's move ->
Engine resolves both in speed order ->
Events animate (lunge, shake, damage, faint)
The battle screen never holds the loadout sheet open: every preflight task either completes before the player commits, or runs lazily inside the battle view itself. The move grid is disabled while the AI resolves the opponent's pick so the player can't double-tap into a stale turn.
Gym tab
│ (advertise + browse simultaneously)
▼
Discovery list
│ (tap a nearby trainer)
▼
Invitation alert on peer device
│ (accept)
▼
Pick your fighter (PokemonPickerGrid)
│ (tap a pokemon)
▼
Pick moves (MoveLoadoutView)
│ - Player picks 4 moves
│ - Sends ChallengePayload to peer
│ - "Waiting for opponent..." until peer submits
│ (both ready)
▼
Battle view
- Same BattleView, driven by MultiplayerBattleViewModel
- Moves committed over MC, host resolves, guest renders events
Background workers fill the on-disk cache at app launch so the UI never waits on the network.
| Worker | What it pulls | When |
|---|---|---|
PokeBattleKit |
Type chart (18 type damage relations) + full move catalogue | App launch via PokeBattleKit.initialize() |
PokemonService |
Full pokedex hydration (detail + species) | App launch |
EvolutionService |
Evolution chains for hydrated pokemon | App launch, background priority |
Sprite colors are resolved lazily on first display through ImageColorAnalyzer (an actor) and cached in-process by pokemon id, so a detail view that opens the same pokemon twice runs the pixel scan once. The pokedex grid pulls the full /pokemon?limit=1150 index in a single request, then fans out detail + species fetches concurrently with progress ticks. Subsequent app launches read everything from SwiftData with zero network calls.
Pixel font, gameboy-style aesthetic, glass effects:
Chip: small inline pill used for type tags, generation badges, status pills, effectiveness markers. Always a 4-point corner radius (capsules look too modern next to the pixel font).MoveCell: shared between the battle move grid and the loadout move picker, switched via aModeenum.TypeColor: centralized type-to-color map used by every move chip, type tag, and weakness grid row.PokemonGrid<Cell>: generic grid taking aGridLayoutand a@ViewBuildercell closure. Handles only scrolling and layout; navigation, search, and overlays are the caller's responsibility.PokedexGridView: wrapsPokemonGridwith color-analyzed cells (dominant-color backgrounds viaImageColorAnalyzer),NavigationLinkrouting, and a loading overlay. Toggleable between 3 and 4 columns.PokemonSpriteCard: sprite-over-name grid cell with type chips. Used by search results, bookmarks, type lists, opponent picker, and multiplayer fighter picker.PokemonPickerGrid: wrapsPokemonGridwith a toolbar search bar and cached haystack filtering (name, type, genus, habitat, legendary/mythical, abilities). Used by opponent picker, multiplayer fighter picker, and bookmarks.GridLayout: single source of truth for grid column counts and spacing across the app (.two,.three,.four).MoveLoadoutView: pokemon summary card +MovePickerGrid+ caller-provided bottom bar slot. Shared across single-player and multiplayer move selection.MovePickerGrid: 2-column move grid with toggle selection and optional type effectiveness annotations.
| Package | What it does |
|---|---|
| Networking | Thin actor-based URLSession wrapper with a generic Requestable protocol driving every PokeAPI endpoint |
| PokeBattleKit | Standalone battle engine: damage calculator, type chart, move/combatant models, and deterministic AI strategies (scoring, heuristics, adjustments) |
dependencies: [
.package(url: "https://github.com/brillcp/Networking.git", .upToNextMajor(from: "0.10.0")),
.package(url: "https://github.com/brillcp/PokeBattleKit.git", .upToNextMinor(from: "0.1.1"))
]- Xcode 26+
- iOS 26+ (for the
FoundationModelsframework,@Observable, SwiftData) - Swift 5 language mode
- Apple Intelligence enabled on the device for the AI features (graceful fallback to deterministic heuristics on devices without it)
- Local Network permission for multiplayer (prompted automatically on first Gym use)
