I finally did it! I have put out the first iOS Testflight build of my first game, Mooselutions, to a private group of beta testers.
If I am being totally honest, I wasn't sure this day would come. I gave it a 50/50 chance that I would cave to Apple and use Xcode to build my game on iOS.
And yet I have somehow prevailed in spite of this battle with corporate sponsored developer tribalism.
I have a peculiar blend of stubbornness, curiosity, and grit that somehow keeps me going in these ridiculous pursuits.
It really feels like I have come full circle. I started my software career as an iOS developer. I cut my teeth making my own apps, and I did some work for some big corporations you would recognize by name.
I learned all of the recommended "best practices," got bored and questioned them, and then felt the need to explore alternatives. Now here I am, about to launch a video game I made from scratch that will be available on multiple operating systems, including some that haven't been created by Apple (Windows for example).
It is fair to say my opinions on iOS development have changed quite a lot since I started over ten years ago.
Why do this?
If I ask ChatGPT what kind of build system I should use for iOS, I get a rather judgmental reply telling me I should stick to using the Xcode project templates Apple provides. It will at least concede that it is possible to do the whole build from the command line with a single bash shell script, but only with the most cautionary tone. "Most teams shouldn't do this. Only do it if your project has custom needs. You're better off with cmake, fastlane, xcodebuild, etc."
Where does it get this tone? Why from you, the people, of course.
In any case, I disagree with ChatGPT (and you apparently).
I don't think you should only roll your own build system if you have special or custom needs. I think it's a good practice for one simple reason. It gives you vision into a process that is usually hidden behind the smoke and mirrors of Xcode. It allows you to actually understand what the fuck is going on with your build. And you can do this procedurally as you would for any other program you write.
When I read through my build script, I can see all of the steps happening in the order in which they actually happen. This is not the case when your project is defined as a build scheme with a big configuration file with all of these widgets and woozles you don't understand.
If it happens on a line in my build script, it happens in the build, and it happens at that point in the build; not before or after. There's a valuable context which helps me reason about the build process, just as you would for any other program.
Why would we ever want to lose this in favor of a promise of convenience that usually doesn't pan out? It's like putting on a blindfold because someone said they can help you walk in the dark.
Forgive my rant. There are other benefits.
If you're an iOS developer, you might be used to the dreaded .pbxproj file and the nasty git merge conflicts it is known to create on large teams. Well guess what? When you don't use Xcode to build your project, you no longer need to use the .pbxproj file for the build. That means you can no longer break the build by breaking the .pbxproj file.
In my experience, you will still need the .pbxproj file to use Xcode as a debugger, but you no longer have to include every source code file in it. You only need to include the files you are actively debugging. The sense of relief at no longer having to manage merge conflicts in this file is palpable. You and your team will be pleasantly surprised.
I would also have to imagine there is a benefit for CI/CD pipelines here. Because every step in the build is spelled out procedurally, it makes it much easier to step through a build that happened on a remote build server. You can reason about the local directories being accessed, where files are getting read and written to.
Because the entire build and deployment pipeline are contained in a single script, you have a nice local context that lets you reason about your entire build automation in one place. I don't know about you, but I hate jumping all over the place trying to put a jigsaw puzzle together.
Lastly, I never need to "clean" the build. That's just not a concept which exists in my build system. What is there to clean? Every time I build my game, I delete all of the old files and build the whole thing from zero. It takes about five seconds on a really fast computer, and up to half a minute on my somewhat slow eight year old Macbook Pro.
Incremental builds just add needless complexity. If you have to think about cleaning up build artifacts, the abstraction promised by incremental builds has broken and you're better off just opting out of it. The existence of the "clean build folder" option is a smell. It means the people who made the build system couldn't bother to make their build system work properly, so they're throwing up their hands and making you deal with it. No thanks, Tim Apple! I'll do it my way.
To recap, these are the benefits:
1. Simple, clear, procedural build steps you can follow from start to finish in a single file.
2. No .pbxproj file merge conflicts.
3. No need to clean the build folder (or derived data), ever. No need to even *think* about cleaning the build folder. Go clean your Skibidi toilet instead.
Project specific considerations
I am a game developer, and games have a common reality that's worth mentioning.
Most games need to be made available on multiple platforms, many of which aren't mobile. My game, Mooselutions, is already available on Steam for Windows and Mac OS. It also runs on the Steam Deck via this handy thing called Proton.
Mooselutions has a single core experience that needs to be consistent on every platform where it ships. It can't rely too much on the specifics of what happens on Windows, Mac OS, or iOS, and that rules out most platform-specific SDKs. If the game relies on UIViewControllers and UIViews, that's logic which only exists on iOS that I would then have to duplicate on Windows and Mac OS. Not good.
That's why the core of the game is written in C++. There is a main update and render loop that's the exact same thing on all platforms.
This gives the game an unconventional structure. The core of it doesn't use any classes or object-oriented programming concepts. Because it needs to be on multiple platforms, the core of it needs to be extremely simple. I chose a more C-flavored kind of C++ for the core of the game for this reason.
If you open up my source code, there's one file big-ass file with about five thousand lines of code and that's the update loop. So I'm clearly not doing the thing every tutorial says you should do where you've got all these classes and need to compile one file for every class.
I only compile one file, and it includes all of the other source code files in the build. This greatly simplifies the line where you do the compile, making it reasonable to do the whole build in a single bash shell script.
We call this a "unity build," not in reference to the game engine but as a separate kind of way to do builds. Have a look at Handmade Hero to see Casey Muratori do this on Windows. You can do the same for Mac OS and iOS too.
I'm reasonably sure Xcode's build system is designed for object oriented thinking. You're meant to add multiple source files which get compiled into object files and then linked together. I simply don't do that, and it makes my build system somewhat incompatible with Xcode by design.
How it went
Spoiler alert, I have succeeded beyond my wildest imaginings. I now have a build system which is mostly separate from Xcode, and it supports all of the features you would expect.
You can do both debug and release builds. You can load the app onto your iPhone from Xcode, and you can still debug source code files inside of Xcode.
The release process spits out an .ipa file which you can then drag into Transporter to upload your iOS app to the App Store. It all works as advertised and with none of the downsides that come with joining yourself to Apple at the hip.
Looking at my github, I can give you a rough timeline of the effort invovled.
I started the iOS port in the first week of November of this year, which puts me at a little over a month from start to first App Store deployment.
Most of my time wasn't spent on iOS-specific logic. It was spent in the game's cross-platform logic, adapting the camera behavior to a smaller screen, adding touch controls and a mobile layout that could also be used on an Android port. I even took the camera behavior I found so useful on iOS and ported it to the game's Steam Deck build.
If I'm being fair, I've probably spent two 40-hour work weeks (at most) dealing with concerns related to Apple platforms.
In that month, I also visited family and went on a snowboarding trip. So it's not like I was doing nothing but sitting in a chair trying to get this game approved. I worked at a leisurely pace.
All of this is to say the effort has been roughly on par with the effort I usually spend on getting an app approved. The truth is, no matter how you slice it, getting your app across the finish line is going to suck. It might as well suck in ways you understand and can control.
If you're counting hours and days, sure, the default Apple Approved (TM) way of doing builds will get you to the App Store quicker. But you do that at the cost of long-term maintainability and comprehension of your build process. I did it that way for years but always felt like I couldn't ever wrap my head around what's happening.
Now I can come back to a game after several months, maybe swap out an expired provisioning profile, run the exact same build script from over a year ago, and be just fine. I recently did this for the Mac OS build for Mooselutions. I built and submitted an update in less than an hour.
What went well
I designed Mooselutions to be cross-platform from the outset. Although I wasn't surprised when I first saw the game running smoothly on my iPhone, it still felt kind of magical to me when coming from a background (iOS developer jobs) where cross-platform is achieved in far less elegant ways.
It's like I took a big plug with a label saying "Game" on it, plugged it into my iPhone, and there it was.
I can also say the port went much more smoothly because I had already finished the Mac OS port. There was so much overlap between the two that I didn't find myself writing a new renderer or audio processing logic. Some of the OS-specific logic was so similar that I took the Mac OS version, threw in some if-defs, and had the iOS version.
Getting the game actually working, when dealing specifically with the parts I planned, went smoothly.
What sucked
Everything that's a third-party dependency which I can't directly control was a pain.
Apple requires all kinds of weird things that don't make sense to a procedural programmer like me. You can't run an iOS app without a launch and main storyboard. I think that's ridiculous because all I need is a rendering loop to run some Metal commands, but Tim Apple begs to differ.
You have to follow all of these rules that just are the way they are, without much rhyme or reason. It's like filling out tax forms. It is a tax. So, here's my advice to help shepherd you past all of this arbitrary bullshit.
Go into Xcode and start a default single view iOS app project. This won't be your actual project, just a reference for the kinds of things Apple expects your build process to spit out.
For example, they have all of these rules around icons. If you try to submit your build and deal with the inevitable errors you get back from Transporter, you will become very confused and frustrated.
Instead of doing that, start up a default iOS app project, put all of your icon files into the Assets.xcassets icon wells, build that default iOS app, locate the build folder, open up the app bundle it spits out, and then observe the contents of the bundle.
What's in the Info.plist that Apple generated? Copy that to your Info.plist.
Which icon files were included in the bundle? Make sure your custom build process has those files with those names in the same locations.
You're basically trying to reverse engineer what Xcode does, replicating its behavior so you don't have to rely on its build system.
Once I got into the habit of making a default iOS app, building it, and then replicating Xcode's output, everything went much more smoothly. Do that (especially when faced with the inevitable cryptic errors you get when submitting your app).
Keep them at arms length
Tim Apple, Bill Gates, Unity Technologies, Google, et. al want you to join their developer tribes because it gives them leverage over your creative output.
If you don’t know how to make your software cross-platform and can only build for Apple devices, Apple gets an exclusivity agreement without having to negotiate with you.
I do things my way because I want Tim Apple to work for the 30% I give him. I want the option to pull the plug at any time, and I don't want the core of my games to be at risk because one of his low skill employees decided we're all doing Model-View-View-Model now.
Apple fights you in this endeavor at every step of the way. Even ChatGPT thinks you're crazy for doing this.
I disagree. I think you're a sane person in a world that has gone insane. The desire for independence is the sign of a healthy thriving human being. My hat goes off to you :-).