I’m building Pulse Gate: Echo Shift alone, for RevenueCat’s Shipaton 2026 — a one-tap timing puzzle game for iOS, native SwiftUI + SpriteKit, no third-party game engine. First commit was July 6, 2026. The app and its In-App Purchase went into Apple review on August 6, were rejected once, and went live on August 20, 2026. That’s 135 commits, six weeks, one designer/developer, no team.This isn’t a highlight reel. It’s the three bugs that shaped the build, why RevenueCat wasn’t optional, why the monetization model changed shape twice, and the two things that went wrong after the code was finished — including one App Store rule change that many iOS Shipaton entrants offering paid content are about to run into.1.1. The mechanic, in one paragraphYou don’t tap a target. A pointer rotates continuously around a central core; tapping locks in an anchor angle at that instant. A pulse then expands outward as a ring of light, and it only registers a hit against a gate if the gate is open, at the correct angle, at the exact moment the pulse reaches it. You’re not aiming at where something is — you’re committing to where something will be. That’s the whole game: 200 levels of gates that rotate, shift, blink, and (from level 61) invert polarity, all built on that one primitive.1.2. Why the monetization model isn’t what I started withEarly on, Pulse Gate had no purchases at all — the concept doc explicitly said „no in-app purchases.“ That didn’t survive contact with the App Store rules or Shipaton’s own economics. The first pivot commit message says it plainly:Level 1–10 free, single 0.99 EUR non-consumable unlocks 11–100. Not a F2P shop — the App-Store-conform „demo + full version“ model.That distinction mattered more than it looks. A single non-consumable unlock is a demo-to-full-version purchase, not a monetization loop — no currency, no gacha, no pressure mechanics, no repeat purchase. It’s the same shape as buying the rest of a shareware game in 1997, just through StoreKit. I extended the campaign to 200 levels/20 chapters later, but the purchase shape never changed: one unlock, one price, forever.The second pivot was mechanical, not philosophical: the purchase had to move off native StoreKit 2 and onto RevenueCat.1.3. RevenueCat wasn’t a choice — it’s a Shipaton eligibility ruleShipaton’s rules are direct about this: the purchase has to be powered by the RevenueCat Purchases SDK. A native StoreKit 2 implementation — which is what I’d shipped first, because it’s fewer moving parts for a single non-consumable product — disqualifies the entry outright, no matter how well it works.So on August 5, one day before submission, I migrated:PurchaseService keeps its public interface but now runs on Purchases.shared: offering ‚default‘ / package $rc_lifetime, entitlement ‚full_unlock‘ as source of truth, customerInfoStream replaces Transaction.updates.The internal call sites in the app didn’t change — PurchaseService still exposes the same isUnlocked / purchase() surface everything else calls. What changed underneath is the source of truth: instead of asking StoreKit’s transaction stream directly, the app now asks RevenueCat’s customerInfoStream whether the full_unlock entitlement is active. RevenueCat, in turn, talks to StoreKit on the app’s behalf and validates receipts server-side.Two RevenueCat details that weren’t obvious going in:Two API keys, two Purchases apps. Debug builds run against RevenueCat’s Test Store app (test_… key) so I can validate purchase flows in the Simulator without touching real money. Release builds use a separate App Store app (appl_… key) tied to the real product. Both live in one config file; the build configuration picks which one loads.The purchase key (P8) is a separate, easy-to-miss step. RevenueCat can’t validate real App Store receipts until you generate an In-App-Purchase key in App Store Connect and upload it to the RevenueCat project. Skip it and every real-world purchase silently fails validation even though the RevenueCat SDK integration looks complete.134 tests stayed green through the migration, including the paywall UI test suite — the interface contract held, only the plumbing moved.1.4. Three bugs, in the order I found them1.4.1. 1. A string literal quietly broke the solvability checkerEvery level in Pulse Gate is JSON, and every level has to be solvable — a Python script (solvability_check.py) simulates each one across a time horizon at a 5ms grid and checks that at least one valid anchor angle exists at some tap time. I trusted that script for the first 60-level balancing pass. Then this, from the commit that fixed it:The script compared direction against 'counterClockwise' (camelCase), the JSONs and RingConfig.swift use 'counterclockwise' — so all ccw rings were silently simulated as cw.Because the string comparison never matched, every counter-clockwise ring in the simulation defaulted to clockwise behavior. The checker wasn’t failing — it was checking the wrong game. Once the case mismatch was fixed, the real numbers came in: 20 levels were actually unsolvable, and 26 more were outside the intended difficulty corridor (K1 15–30% real-world win rate down to a 2.3% finale). All 46 got rebalanced, primarily by adjusting gate phaseOffset/centerAngle against the anchor angle, with opensAt/closesAt timing windows and a capped widthAngle bump as secondary levers.The lesson that generalizes: a validator that always returns green isn’t evidence the thing it validates is correct — it’s only evidence the validator ran. I only caught this because I re-ran the check after an unrelated change and got suspicious when the pass rate didn’t move at all across a balancing pass that should have shifted it.1.4.2. 2. The pulse looked wrong on-device, but the game logic was rightA device tester on an iPhone 13 reported that the expanding pulse „never makes it through the first ring“ on shifting-gate levels, and that gates that looked open blocked the pulse anyway. The game logic — arrivalTime = tapTime + radius / pulseSpeed, and gate open/closed state driven by that same scene clock — was correct and had been since an earlier fix. The bug was purely visual, and it took a full causal chain to track down:PulseNode integrated its visible expansion per frame with a capped dt (min(rawDt, 1/30s)). Every frame over 33ms — systematically, on-device: AVAudioPlayer start in the tap frame, shader compile on the first pulse, thermal throttling — let the visible wave permanently fall behind the logic clock, cumulatively, by up to rawDt - 33ms per hitch.So the arrival-time math was always right, and the gate-open math was always right, but the rendered radius of the expanding ring silently drifted behind both of them on real hardware (the Simulator, notably, never reproduced it — no thermal throttling, no real audio driver latency). By the time a shifting gate’s open window closed, the pulse looked like it hadn’t reached the ring yet, even though the engine had already scored the hit or miss against the correct ring at the correct time.The fix replaces frame-accumulated dt with a direct derivation from the scene clock:// Before: radius accumulated per-frame, drifting under any frame hitchpulse.update(deltaTime: dt)// After: radius derived directly from the same clock the engine already// uses for arrivalTime — frame pacing becomes irrelevantpulse.update(sceneTime: sceneCurrentTime)PulseNode now computes radius = (sceneTime - tapTime) * pulseSpeed — the exact inverse of the formula the collision engine already used to compute arrivalTime. Same clock, same tapTime origin, so by construction the visible wave is exactly on the ring’s radius at the moment the engine evaluates it, independent of how many frames were dropped getting there. Seven new unit tests lock in the frame-drop-immunity property specifically, since it’s the kind of bug that a normal Simulator run will never surface.1.4.3. 3. The launch icon that rendered at billboard sizeThis one is universal enough that it’s worth flagging for anyone shipping a custom UILaunchScreen without a storyboard. I’d built a 1024×1024px PNG for the launch icon and dropped it into the imageset’s 1× slot, leaving 2x/3x empty. iOS doesn’t scale that: with only the 1× slot filled, it reads the PNG’s pixel dimensions as points, not pixels. A 1024×1024px image becomes a 1024×1024-point icon — several times wider than any iPhone screen, centered and cropped, on every device, every scale factor.The fix was one line of intent, applied by re-exporting the asset at the actual target size:Shrink native launch icon to a 36×36pt core dot (was rendering at 1024×1024pt)If you only fill one slot in an Apple imageset, pixel measurement equals point measurement on every device — size the source file for the point size you actually want, not for „big enough to look sharp.“1.5. The rejection wasn’t about the codeBuild 10 — the app, the Full-Unlock IAP and 8 Game Center achievements — went to review together on August 6. Bundling is not optional: Apple requires the first IAP to ship alongside the first app version it belongs to.It came back rejected under Guideline 2.1, Information Needed. Not a single line of the binary was the problem. Apple wanted to know who the app is for, which external services it talks to, whether content differs by region, what the IAP actually unlocks, which physical devices I had tested on, and a screen recording of the purchase flow.Two things are worth knowing about that:No new build was required. I answered in the review thread and pasted the same information into the App Review Notes field. The version status stayed on „Rejected“ the whole time, which is normal for a metadata rejection until a reviewer picks the answer up. Four days later it was approved.Answer it in full the first time. My reply named the exact devices and OS versions (iPhone 13 on iOS 26.5.2, iPhone 13 Pro on iOS 27.0 beta, both physical; iPad verified in Simulator only, stated plainly rather than glossed over) and linked an unlisted YouTube recording showing install, the free levels, the paywall, the purchase and the unlock. There was no second round.1.6. The rule change nobody told me about: promo codes are goneThis is the one I would have paid for in advance.Every hackathon entry with paid content has the same problem: the judges need to see it. The reflex is App Store promo codes. That reflex is now wrong. As of March 26, 2026, App Store Connect no longer lets you create promo codes for In-App Purchases. Existing ones still redeem until they expire; new ones cannot be created at all.The replacement is offer codes, which Apple expanded to cover consumables, non-consumables and non-renewing subscriptions, not just auto-renewable subscriptions. They live in a different place — App Store Connect → your app → In-App Purchases → the product → Offer Codes → Create Offer — and they behave differently in three ways that cost me time:One-time-use codes come in batches of at least 500. There is no „generate three codes for the judges“ option.A one-time-use code is bound to a single Apple ID. Hand the same link to five judges and four of them get an error. What you actually want for a panel is a custom code (a vanity code, in the same offer), which multiple people can redeem.The expiry is set per batch, up to six months. My first batch expired September 30 — the Devpost deadline. Judging runs October 1–13. The codes would have been dead on arrival for exactly the audience they were made for.None of this requires an app update. Offer codes redeem through a URL of the form apps.apple.com/redeem?ctx=offercodes&id=&code= or through the Redeem screen in the App Store app. In-app redemption exists too, but it needs a StoreKit call you have to implement — for judges and testers, the plain link is enough.1.7. The launch-day bug that wasn’t in the appI released the version before the support site was finished. Ten minutes later, the page the App Store listing points at was serving without any styling, with every internal link dead.The cause was one missing file. The site lives on GitHub Pages, and GitHub Pages will not serve a custom domain until a CNAME file exists in the repository. Without it the site fell back to the project path, where every absolute reference — /assets/style.css, /support, /impressum — resolved one level too high and 404'd.There is a second trap right behind it, which I walked straight into while fixing the first: GitHub Pages resolves /impressum to impressum.html before impressum/index.html. So the obvious „clean URLs plus a redirect from the old path“ arrangement makes the redirect point at itself. The privacy policy and the legal notice — the two pages that legally have to work — spent a few minutes in a redirect loop.The generalizable version: your store listing is not the finish line. It is a set of pointers, and everything they point at ships at the same moment they do.1.8. Where it stands nowLive in 175 countries since August 20, free to download, with a single $0.99 non-consumable unlocking levels 11–200. 200 levels across 20 chapters, solver-verified 200/200 solvable after the case-sensitivity fix above turned out to matter. A Game Center leaderboard for Endless mode shipped with 1.0.Crash reporting is wired but deliberately not in this build. Sentry is in the codebase, configured as narrowly as I could make it — no tracing, no session replay, no screenshots, sendDefaultPii = false, crash reports only — because the app’s own promise is „no tracking“ and I did not want to break it quietly. It ships with 1.1, together with the App Privacy label change that honestly declares it. Shipping a diagnostics SDK under a privacy label that predates it would have been the easy version and the wrong one.1.9. What I’d tell another solo Shipaton builderCheck the mandatory-SDK rule before you write the purchase code, not after — migrating a working StoreKit 2 flow to RevenueCat one day before submission was avoidable stress.Don’t trust a validator’s silence as proof of correctness, especially for anything procedurally generated or simulated. Re-run it after unrelated changes and get suspicious when the numbers don’t move.Sort out judge access early, and check the expiry date against the judging window rather than the submission deadline. Those are different dates, and the codes have to survive the later one.And budget real time for the parts with no code in them. The game was finished for weeks. What took the last stretch was a support page, a privacy policy, a German legal notice, store metadata in two languages, a screen recording, and a rejection that had nothing to do with the software.Pulse Gate: Echo Shift is built solo — native Swift, SpriteKit and SwiftUI, no game engine, no ads, no tracking. Live on the App Store since August 20, 2026, built for RevenueCat’s Shipaton 2026: apps.apple.com/app/id6788528869