C++ Has to Go
Day 3 of building an iOS game with Claude Code. Frame drops, half-hour prompts, and the moment I almost gave up before ripping out the entire engine.
On day 2 I was euphoric. The iPhone port worked. Nine commits in a day. "There is no more tech gap." By day 3 afternoon, I was almost ready to quit.
This is the post about that day.
The architecture was bleeding out
Remember the architecture from day 2? C++ engine at the core, Swift/SwiftUI wrapping it, Objective-C++ bridge in between. On paper: the best of both worlds. In practice, by day 3 afternoon, it was the worst of both worlds.
The first symptom was frame drops. The rendering loop had to push state through the Obj-C++ bridge on every frame. Swift asks the C++ engine for positions, C++ hands them back, Swift draws them in SwiftUI. That round-trip is expensive. At 60fps, the rendering pipeline had exactly 16 milliseconds to do everything, and the bridge overhead was eating a meaningful chunk of it. The game stuttered when too much was happening on screen.
Frame drops I could have lived with temporarily. The real problem was worse.
Every prompt to Claude was becoming a half-hour ordeal.
Any change I wanted to make now required Claude to hold three things in context simultaneously: the C++ game engine, the Swift/SwiftUI shell, and the bridge that connected them. Simple changes cascaded. I'd ask to add a sound effect and Claude would have to trace how sound events propagate from the C++ physics callbacks, through the bridge, up to Swift, to the audio engine. Every step was a place for things to go wrong.
And things did go wrong. Constantly. Small changes broke in subtle ways. Prompts that would have taken five minutes on day 1 now took thirty. The feeling of momentum I'd had for 48 hours was evaporating.
The moment I almost quit
I want to be honest about this part because it matters to the rest of the series.
I came very close to giving up on the whole project on day 3.
Not because I was frustrated. I know how to push through frustration. Because the fun was gone. Days 1 and 2 had felt magical. Day 3 felt like I was slogging through a bureaucracy. Every small change was a bureaucratic form to fill out, submit to Claude, wait for the response, check if it was right, push back, wait again. The signal-to-noise ratio had collapsed.
And there's something important about the timing. The project had just started to feel like it could become something real. That's a fragile state. It's much easier to give up on a project that feels doomed than on a project that feels promising. Day 3 was the first time I had a working iPhone prototype and the feeling that the architecture was a dead end. The dissonance was painful.
What kept me going wasn't willpower. It was a debugging intuition I picked up years ago at my day job: when something stops being fun, there's usually a specific culprit, and finding it is the most useful thing you can do. Not "power through." Not "take a break." Find the specific thing that's making this painful and fix that.
The culprit here was obvious once I stopped moving. The Obj-C++ bridge was the tax on every prompt. Every feature I added made Claude's job harder because the architecture had too many moving parts for even a strong model to keep straight. The fix wasn't "push harder." The fix was "change the architecture."
The groundwork
Before I actually pulled the trigger on the rewrite, I did something that looked boring but mattered a lot. I shipped PR #5: "Move themes and gameplay tuning to runtime config." Fifty-seven files touched.
The title makes it sound like a code-hygiene PR. It wasn't. It was a deliberate setup move. I extracted every tuning value in the game (platform themes, difficulty curves, physics constants, color palettes, animation timings) out of C++ headers and into a runtime JSON config. On the surface: nicer code organization. Underneath: a specific bet.
If I was about to rewrite the engine (and at that point I suspected I was), I wanted the tuning values to survive the rewrite. Values in C++ headers don't port cleanly across a language swap. Values in JSON config files do. PR #5 was insurance against a future I hadn't committed to yet.
This is the first moment in the project where I did something forward-looking. Days one and two were pure exploration: try things, see what works. PR #5 was the first time I asked "if this goes sideways, what do I want to be true?" and then made it true before I needed it. The mental shift from research/playground to product starts here.
Never take Claude's word for it
Deciding to rip out the C++ engine meant deciding what to replace it with. I knew I wanted native Swift for the game logic. What I didn't know was how to render the game at 60fps once Swift was doing everything.
My Metal knowledge going into this conversation: zero. Graphics API knowledge in general: basically zero. I had a vague idea that Metal was Apple's thing for fast graphics and that's where my expertise ended.
So I did what I always do for architectural decisions: I researched every angle with Claude, but I never took Claude's word for it. That phrase has become a personal rule for this whole project and I mean it literally. Claude is good at giving you a confident answer. That doesn't mean the answer is right. For any non-trivial decision, the workflow is:
- Ask Claude what it would do
- Ask Claude to explain why that approach and what the alternatives are
- Ask Claude to show me the primary sources (Apple docs, reference code, blog posts from people who've done this)
- Read the primary sources myself
- Come back to Claude with follow-up questions grounded in what I read
On the Metal decision specifically, I spent a couple hours doing this before I wrote a single line of code. I read Apple's Metal best practices. I looked at the SpriteKit vs Metal comparison (could have used SpriteKit, decided against it for performance ceiling reasons). I skimmed a couple of indie game dev blog posts from people who'd built 2D games directly in Metal. By the time I sat down to execute, I could answer "why Metal and not X" for every X.
This sounds like a lot of work. It is. It's also the thing that makes Claude safe to use at my level of domain knowledge. Claude can make bad choices, and when you gain too much confidence in it you stop noticing. I'll say this again in a different form every few posts because it keeps being true.
The rewrite
Day 3 ended with the decision made and the groundwork in place. I didn't write the rewrite on day 3. The rewrite shipped on day 4, March 19, 2026, as a single commit:
Native Swift runtime + Metal gameplay renderer (#6) — 93 files
Ninety-three files smaller than the day-2 PR but architecturally more significant. This is the commit that locked in the module structure that the game still uses today:
- GameRuntime: pure Swift, deterministic game logic. No UIKit, no Metal, no SwiftUI imports. A pure function from inputs to typed output structs
- GameRenderer: Metal-only, reads the typed
RenderFrameoutput and draws pixels - GameContent: pure data types, no logic
- GameAudio: audio commands as typed data, processed by an AVAudioPlayer-based engine
- GeoClimberApp: the SwiftUI shell, menus, pause, game-over dialog, touch controls
Strict layering. Strict module boundaries. Every data flow typed. No dictionaries, no Any, no ObjC bridging. I get to forget Objective-C++ exists, which as someone who never learned it is a relief.
I expected porting the physics from C++ to Swift to be the hard part. It was the easy part. Claude translated the constants, the integration math, the collision detection, the jump curve. All cleanly. The physics behave identically on both sides. I compared them by hand. "Like a dream" is the exact phrase that came out of my mouth the moment it worked.
And the bridge overhead? Gone. 60fps. Consistently. The stuttering that had been eating my momentum on day 3 evaporated.
What the rewrite actually cost me
Here's what the rewrite cost, honestly: one day of work and a lot of nerve. That's it. The physics ported cleanly. The Swift shell that replaced the Obj-C++ bridge was smaller and easier to reason about. The new GameRuntime module became a clean boundary between game logic and rendering. Pure Swift, zero side effects in the game logic layer, game state as typed output structs that the renderer consumes.
None of that was planned as architecture on day 3. I didn't sit down and design a clean layered system. I was desperate, the bridge was killing me, and desperation forced me to pick the simplest separation I could think of. Game logic in one place. Rendering in another place. A typed data structure between them. That's it.
I mention this because there's a romantic version of "software architecture" where you sit down, think carefully, draw diagrams, and produce an elegant design. There's also the version where you're on day 3 of a side project, it's not working, you're about to quit, and you reach for the simplest possible separation because you don't have energy for anything fancier. Both produce architecture. The second kind is usually better.
I almost quit. I came very close to closing the laptop on day 3 and calling this an interesting two-day experiment. The thing that kept me going was noticing that the culprit was specific and fixable. The project wasn't doomed. One particular architectural decision was doomed, and I could make a different one.
If you're ever on day 3 of your own project and it stopped being fun, look for the culprit. It's probably one thing. Fix that.
This is post 3 of 18 in a series about building Geo Climber with Claude Code. The Metal architecture from day 3 is still the foundation today. Join the Discord and download Geo Climber on the App Store.