Simple iOS app that presents a list of games using the RAWG API.
Built with Hexagonal Architecture, Spec-Driven Development (SDD), and MVVM as the UI presentation pattern. Requires iOS 26.4+ and Swift 6.
This repo is set up so humans and AI agents share the same source of truth:
| Piece | Where | Purpose |
|---|---|---|
| Agent protocol | AGENTS.md | Phase gates for spec → ports → tests → implementation |
| Always-on rules | .cursor/rules/ |
Hexagonal boundaries, SDD, playbooks, memory protocol |
| Skills | .cursor/skills/ |
Project workflows (sdd-feature, hexagonal-ios, playbooks, save-changes) plus Apple Xcode 27 skills (swiftui-specialist, …) |
| Specs | specs/<feature>/SPEC.md |
Feature truth (BDD acceptance criteria) |
| Memory bank | memory/ | Per-branch progress, session context, and decision log |
| MCP | below | Simulator exploration, Xcode build/test/preview, and compiler-accurate Swift code navigation |
The memory bank is tracked in git so context follows the branch (same protocol as srxy). Agents read it at session start and update it at milestones — see memory/README.md.
Business logic lives in a local Swift package, GamesLibraryCore, with compile-time isolation from UI and I/O:
| Layer | Location | Responsibility |
|---|---|---|
| Core | GamesLibraryCore/ |
Domain entities, inbound/outbound ports, use cases |
| Inbound adapters | GamesLibrary/Adapters/Inbound/ |
SwiftUI views, @Observable ViewModels, formatters |
| Outbound adapters | GamesLibrary/Adapters/Outbound/ |
DTOs, mappers, repository, cache, network |
| Composition root | AppContainer, AppCoordinator |
Wires dependencies; ViewModels receive inbound ports only |
Dependency flow: Views → ViewModels → Use Cases → Repository Port → Network/Cache
Local helper packages: IOSConveniences, SwiftUIComponents, GamesLibraryUITestKit, GamesLibraryAccessibilityIdentifiers.
See docs/ARCHITECTURE.md and docs/playbooks/ for the full playbook reference.
- ASTK (
AsyncSharedTestingKit) — shared-process UI testing framework extracted from this app: launch once, apply Codable scenarios at runtime, drive screens with async page objects. See UI tests. - OptimizedAsyncImage — used instead of SwiftUI
AsyncImageprimarily for ImageIO downsampling viatargetSize(list/detail thumbnails). NativeAsyncImagegained HTTP caching on iOS 27+, but it does not downsample; revisit only if the deployment target is iOS 27+ and downsampling is reimplemented or dropped. - HTTIES, BetterLogger, TTLCache — HTTP client, logging, and in-memory TTL cache.
Features are defined in specs/<feature>/SPEC.md before code changes. The repo is the source of truth; Jira/Linear track work.
Phase Gate Protocol (enforced for agents in AGENTS.md):
- Spec — write/update
SPEC.mdwith BDD acceptance criteria; get approval - Architecture — add ports and domain types in
GamesLibraryCore; get approval - Tests — map BDD scenarios to Swift Testing tests
- Implementation — adapters and UI until tests pass
Current specs: games-list, game-details
This project configures MCP servers for agent workflows:
| Server | Config | Purpose |
|---|---|---|
ios-simulator |
.cursor/mcp.json (Cursor), .mcp.json (Claude Code) |
Explore live UI on a booted simulator (get_ui_tree, tap, type) before writing XCUITests |
xcode-tools |
~/.cursor/mcp.json (Cursor, global), .mcp.json (Claude Code) |
Build, run tests, render SwiftUI previews from Xcode |
codenav-swift |
.mcp.json |
Compiler-accurate navigation and editing via sourcekit-lsp: symbol_info, references, callers, implementations, type_at, outline, diagnostics, plus compiler-checked edits (below) — by symbol name instead of grep |
Enable the Cursor servers in Cursor → Settings → MCP. See docs/playbooks/ios-simulator-mcp.md for setup and the SDD explore → interact → codify UI-test workflow.
Setting up codenav-swift: install it with Homebrew, generate buildServer.json for the Xcode project, and build the scheme once so the index exists (details in AGENTS.md):
brew install illescasDaniel/tap/codenav-swift-mcp xcode-build-server
xcode-build-server config -project GamesLibrary.xcodeproj -scheme GamesLibrary --build_root "$PWD/DerivedData/GamesLibrary".mcp.json is checked in and registers all three servers for Claude Code. It sets no workspace: codenav-swift resolves the repo from the client's roots, then CLAUDE_PROJECT_DIR, then the current directory. Clients that start servers elsewhere (Cursor) must pin it with CODENAV_SWIFT_WORKSPACE. The first xcode-tools call asks Xcode to approve the agent: have Xcode open and call XcodeOpenWorkspace on GamesLibrary.xcodeproj.
Compiler-checked code editing (codenav-swift 0.2.0): the write tools show each change to sourcekit-lsp in memory first and refuse to write it if it introduces new compile errors; when other modules are affected they also run an xcodebuild build-for-testing. Every applied edit gets an id, and undo_edit reverts it.
| Tool | What it does |
|---|---|
rename_symbol |
Rename through the type checker (overloads, witnesses, extensions, labels) |
change_signature |
Add/remove/reorder/retype parameters and rewrite every call site, tests included |
edit_symbol, insert_member, apply_edit |
Rewrite a declaration by name, add a member, or apply multi-file text edits |
move_symbol, delete_symbol |
Move a declaration to another file/type; delete one (refuses while it is still used) |
add_conformance, refactor, fix_diagnostics |
Protocol stubs, sourcekit-lsp refactorings (extract method/expression), compiler fix-its |
check_edit, undo_edit, verify, affected_tests |
Dry-run a change, revert an applied one, build/test the project, find the tests that cover a symbol |
Write tools are opt-in: add "env": { "CODENAV_SWIFT_WRITE": "1" } to the codenav-swift entry in .mcp.json (without it the server is read-only). Pass dry_run: true to any of them to preview the diff without writing. Under the repo's hexagonal rules, prefer these over blind text edits so layer boundaries survive refactors.
Alternatively, claude mcp add codenav-swift --scope user -- codenav-swift-mcp registers it for every project without a file. Apps that don't inherit your shell PATH (Cursor, for example) need the absolute command: /opt/homebrew/bin/codenav-swift-mcp.
- Game list with search, pull-to-refresh, and infinite scroll
- Game details screen
- English and Spanish (String Catalog)
- In-memory TTL cache (5 minutes)
- Shared-process UI tests via ASTK (no live API)
- Swift 6, Swift Testing
- Compiler-accurate code navigation and compiler-checked, undoable code editing for AI agents via codenav-swift-mcp
The project requires an API key from https://rawg.io/apidocs (free tier).
Copy Secrets.swift.sample to GamesLibrary/Configuration/Secrets.swift and set your RAWG API key. Copy Config.xcconfig.sample to Config.xcconfig and set your Apple DEVELOPMENT_TEAM. Both local files are gitignored. The API key is a plain Swift string literal (not in Info.plist).
Security: That string is still easily recoverable from the app binary (strings, disassembly). Do not ship real production API keys in client apps — prefer a backend (BFF) that holds secrets and authenticates the client. This demo’s client key is intentional learning debt, not a pattern to copy.
Previews use mock use-case ports — no live API key required.
# Core package tests
cd GamesLibraryCore && swift test
# App unit tests and UI tests: Xcode scheme GamesLibrary / GamesLibrary.xctestplanUI tests are serial (parallelizable: false) because they share one app process.
UI tests use ASTK (AsyncSharedTestingKit), a shared-process framework extracted from this app and published for reuse. It targets the two expensive parts of XCUITest: cold launching per test and blocking sequential waits.
Shared-process launch — the suite starts the app once (UITESTING=1) and applies each scenario at runtime via a Debug-only deep link (gameslibrary-uitest://apply?config=…). SwiftUI recreates the root with .id(generation) so ViewModels and .task loaders run fresh, without a process restart. Stubs are wired at the composition root through UITestConfiguration; no network, no API key, no dependency on RAWG uptime. Release builds do not register the test URL scheme.
Async page objects — pages live in GamesLibraryUITests/Pages/ and import the ASTKXCTest product. Element accessors are get async throws: they wait via XCTWaiter (XCTNSPredicateExpectation + fulfillment) instead of blocking on sync waitForExistence. When several elements on the same screen appear together, tests wait in parallel:
async let rating = details.rating
async let year = details.year
async let playtime = details.playtime
_ = try await (rating, year, playtime)On an iPhone 18 Pro simulator (8 UI tests, Sep 2026), switching from sync waits to async + async let cut total suite time from 70.3s → 60.4s (~14%); the metadata-chips test alone dropped 12.4s → 8.1s (~35%) because five sequential waits became one concurrent wait. Combined with one launch for the whole suite, that removes both inter-test launch cost and in-test idle time.
See the ASTK README for the reusable framework and docs/playbooks/GamesLibrary-adaptations.md for POM layout, shared-process launch, and scenario config in this app.
Agent memory bank (per-branch project state): memory/README.md.