Hey everyone. I want to share my first real experience building a mobile game for iOS — a roguelite. It was a pet project that, to my own surprise, grew into an App Store release, so I’ll cover both the idea and the technical bits. Hope it’s an interesting read.

HOW IT ALL STARTED

I was taking an iOS development course and figured the best way to actually retain the material was to build a few real projects. One of them became LootShrinelink to the App Store.

It’s a roguelite focused on inventory management and turn-based combat: multiple modes (an endless mode and a "story" run across 100 floors), you climb a tower of 100 floors, collect loot, equip it, or sacrifice items at the shrine for permanent power that lasts the run. Every item is a choice: equip it, "absorb" it for a temporary battle buff, or sacrifice it. A shout-out to Megaloot, which inspired me — the idea for a game like this partly came from playing it, since I couldn’t find anything comparable on mobile.

Next — how it actually works under the hood.

STACK & ARCHITECTURE

Let me note one key constraint I set from the start: no custom backend — only Apple’s built-in services, because I didn’t want to over-complicate things (and it’s expensive right now). This was a conscious decision (for a solo project I don’t have the time or the interest to also stand up and maintain a server), and it shaped a lot of the architecture: saves, sync, competitive features — everything is wrapped into CloudKit and Game Center (more on this below).

The base stack is pure SwiftUI (iOS 17+/macOS 14+), no third-party UI frameworks. Only two external SPM dependencies: RevenueCat for in-app purchases and TelemetryDeck for analytics. I want to specifically call out TelemetryDeck — it’s a privacy-first solution: only anonymous aggregated statistics, nothing tied to the player’s identity (which makes App Store review easier, and makes it easier for players to install a game that isn’t asking for extra permissions). Everything else is Apple’s native stack.

Architecturally I went with a Redux-like approach rather than MVVM or a ready-made engine (though that variant would definitely have simplified some development), and here’s why it turned out to be a good fit for the game (or at least I think so):

  • Single source of truth. All game state lives in one Codable struct GameState (with sub-states inside: RunState, PlayerState, BattleState, ShopState, MetaState, etc.). For a game this matters: dozens of screens need to see a consistent state.
  • Actions describe events. There’s one enum GameAction that lists everything that can happen in the game: playerAttack, purchaseItem, sacrificeItem, advanceFloor, endRun, and so on.
  • Reducers are pure functions of the form (State, Action) -> State, with no side effects: no I/O, no network, no hidden random state inside. Turn-based combat maps beautifully onto the model “state → action → new state”.
  • Store built on @Observable. I use the native Observation from iOS 17 (@Observable) rather than ObservableObject/Combine — less boilerplate and more precise view invalidation.
  • Views just dispatch. Views never mutate state directly, only store.dispatch(.action).

Separately about reducer orchestration: there’s a root gameReducer that receives an action and delegates it to specialized sub-reducers — BattleReducers, ShopReducers, ShrineReducers, RunReducers, EventReducers, MetaReducers, and others. So the root reducer is a dispatcher, and all the domain logic is broken up across modules. This decomposition helps a lot when combat logic grows: my combat reducer file is already several thousand lines, and without splitting it, it would be hell.

Content (items, enemies, sets, zones) partially lives in JSON, partially in declarative Swift tables. Side effects (saves, sound, item generation, Game Center, cloud) are moved to a separate services layer.

SPRITE RENDERING: ATLASES ON TOP OF SWIFTUI

All the pixel art — enemies, items, skill icons — is rendered in SwiftUI by slicing large texture atlases. Meaning I don’t store thousands of separate PNGs (in some cases I do have individual PNG/JPG assets, but atlases are the main approach); instead, I keep big atlas sheets and cut out the needed frame: Image(uiImage:) with interpolation(.none) (so the pixel art stays crisp), and offset/crop based on the sprite’s coordinates in the atlas. That’s how monster sprites, item icon grids, and skill-tree icons are all done.

Important note: the sprites themselves are SwiftUI. I brought in SpriteKit later, and only for one layer — visual effects.

WHERE THE ART CAME FROM: CHATGPT + ASEPRITE

I’m not an artist or an art director, but let me say a bit about the “graphics pipeline” too. I generated sprites, icons, status effects, and art in ChatGPT, and then finished them by hand: centered them, cleaned them up, normalized them to a common size, and packed them into texture atlases in Aseprite (a pixel-art editor). Those big atlases I mentioned above are the result of hand-assembly in Aseprite. Generation gives you starting pieces fast, but without manual polish and careful atlas layout, it just doesn’t fly in-game.

FINE POINT #1 — WHY THE SPRITEKIT LAYER APPEARED

Originally the game was fully SwiftUI, including hit effects, deaths, and so on — I drew them as SwiftUI overlays on top of combat. And a problem emerged: animation timing in SwiftUI is unreliable (or maybe I just don’t know how to prepare it), because it’s tied to the view lifecycle. An effect could cut off mid-play, play at the wrong time, or not play at all during a redraw.

I thought about it and decided to move all combat VFX into a transparent SKScene (SpriteKit) overlaid on top of SwiftUI combat. SpriteKit gives reliable SKAction.sequence with predictable timing that doesn’t depend on SwiftUI redraws. Essentially, one scene replaced a pack of SwiftUI overlays (attack effects + death “puffs”).

But a bridge appeared between the two layers, and this is exactly the bottleneck I plan to refactor (because even at this level of graphics complexity, it burns quite a lot of resources):

  • SwiftUI is responsible for positioning sprites on screen. I measure their coordinates via SwiftUI (shared coordinate space / GeometryReader) and pass them into SKScene as positions where the effect should hit.
  • Inside the scene, coordinates are converted from the SwiftUI system into the SpriteKit system (SpriteKit has an inverted Y axis — origin at the bottom).
  • Further, effects with “attachment” — like dripping poison, bleed, or DOT — follow the sprite frame-by-frame: the scene, on each frame’s update, repositions the emitters onto the current coordinates of the “floating” sprites.

It works — but essentially I have two render pipelines (SwiftUI + SpriteKit) living side by side and syncing via passing coordinates every frame. That’s the point for optimization: I want to move to a more performant, cohesive scheme so I’m not shuttling positions back and forth.

FINE POINT #2 — TWO-PART EFFECTS

Each hit/skill effect is assembled from two layers:

  • Frame animation from an atlas — a sprite strip played via SKAction.animate(with: textures, timePerFrame:). Textures are cached in SKTexture ahead of time so they aren’t assembled on the fly.
  • SpriteKit particlesSKEmitterNode on top (sparks, splashes, DOT drips, etc.).

Crits, by the way, are a separately layered animation on top of the base hit, and “kill” is a sequence: first the attack effect plays, and after it completes — the death effect. It’s all fire-and-forget: nodes remove themselves after playing.

FINE POINT #3 — “BREATHING” AND FLYING MOBS

Mobs aren’t static — they have an idle animation, a custom SwiftUI animation/ViewModifier:

  • “Breathing” — a slight non-uniform scaling of the sprite (stretch), anchored to the sprite’s “feet” so the mob looks like it’s breathing rather than jumping. Originally I did a simple vertical “bob” (a hop), but it read as cartoonish and interfered with the breathing — in the end I kept just the breathing, which looks more “expensive.”
  • Flying enemies get a hover offset on top of the breathing — the modifier adds organic float along a 2D trajectory.

So it’s all an honest SwiftUI animation layer on top of atlas sprites, no SpriteKit.

NO BACKEND: CLOUDKIT + GAME CENTER

As I said at the start, the game has no custom backend — everything is built on Apple’s native stack.

Game Center (GameKit) handles the competitive side:

  • Leaderboards — over 73 of them (global and per-class, on various metrics: floor, bosses, gold, souls, etc.).
  • Achievements — 90+.
  • Score submission is fault-tolerant: if there’s no network or the submit fails, the result goes into an offline queue and gets re-sent later (with persistence), so nothing is lost.

CloudKit handles everything to do with player data and social features (specifically CloudKit, not Game Center):

  • Cloud saves and their sync across devices (for family-shared devices: if someone bought the full version of the game, family members’ devices get the full version too).
  • Ghost Challenges — you can challenge a friend with a shared code; the “ghost” run goes through the public CloudKit DB, and players “play” against each other.
  • Hall of the Fallen — a public pool of recently-dead runs from other players, which you can try to “beat” without codes and without friends.
  • Player profiles (cosmetics/avatars for leaderboard display).

This approach gave me a free and reliable infrastructure without a server side — for a solo dev, that’s a huge savings of effort.

BONUS CHALLENGE — BACKWARD-COMPATIBLE SAVES

Since the game is already released and people have saves, and I’m still supporting and updating the game — adding new content — any change to the state schema can break someone’s progress. Codable turned out to be treacherous here (I broke my own saves locally a couple of times, though never in production): if you add a new non-optional field to a state struct, the old-save decoder crashes with keyNotFound, even if the property has a default.

So I gave myself an iron rule: new fields in state — only Optional? with = nil, and read via ?? <default>.

TOOLS AND AI

And of course during development I couldn’t manage without agents (sorry) — I use Claude from Anthropic. Plus ChatGPT for art generation (see above). AI is good at accelerating routine work and drafts, but the architecture, atlas assembly, and balance polish all still have to live in my head and be done by hand.

HOW IT ALL ENDED UP

What started as a course pet project turned into a real App Store game: solo development, native stack, Redux architecture, a SwiftUI + SpriteKit hybrid, and the entire infrastructure on CloudKit + Game Center with no backend of my own. Along the way I got hands-on with a bunch of things — from the limits of SwiftUI in animations to schema evolution in production.

If you have questions, I’ll happily answer them. Thanks for reading!

// TRY IT ON YOUR PHONE

DOWNLOAD LOOTSHRINE FREE

First 10 floors free on the App Store. If you’d like to see the systems this article is about in action, that’s the best place to start. Questions? Email me at support@lootshrine.com — goes straight to me.

APP STORE