The Game Is Live. The Interesting Bugs Arrived After.

Roadie Wars 5 is on the App Store. The week after launch turned up a class of bug you can't find before you ship: the kind where nothing crashes, nothing logs, and the game just quietly does something else.

Roadie Wars 5: Stage Climb is live on the App Store. The Xcode project was created on August 9th. It was approved and on sale twelve days later. That part, after all the fuss in part five, went fine.

What I want to write about is the week after, because shipping surfaced a class of bug I don't think you can find any other way. Not crashes — crashes are easy, crashes tell you where they are. These are the ones where nothing throws, nothing logs, and the game quietly does something else instead.

Seven missing numbers deleted the entire story

The game has 27 authored cutscenes across 70 comic panels — stage briefings, boss intros, the cold open. I installed a TestFlight build and none of them played. The game ran the handful of built-in placeholder scenes instead, which is exactly what it would look like if I'd never wired the story up at all.

The loader was four lines:

guard let url = Bundle.main.url(forResource: "cutscenes", withExtension: "json"),
      let data = try? Data(contentsOf: url),
      let decoded = try? JSONDecoder().decode(CutsceneLibrary.self, from: data) else {
    return fallback
}

The whole library decodes in one call. So one malformed actor doesn't cost you one scene — it costs you all 27. And try? throws away the DecodingError, which is the only thing in the entire system that knew which field of which panel was the problem.

The culprit: seven missing numbers. An actor's y is a lift off the panel floor, and zero is the normal value for basically everyone. Something upstream only wrote y when it was non-zero — the classic falsy-value bug — and Actor.y was a non-optional Double. Two actors in two scenes had no y. That deleted the other 25 scenes as collateral.

Fixed in three places, and all three are the actual lesson:

  1. The loader now uses do/catch and prints the error. DecodingError's coding path names the scene, the panel and the field — it's a complete diagnosis, and the old code was throwing it in the bin.
  2. Actor got a hand-written decoder with defaults. Geometry has obvious neutral values, and three different programs write that file — an editor, an AI wizard, and me in vim. Only one of them is careful.
  3. A validator that checks the JSON against what Swift will actually accept, so this is a one-second check rather than a build-and-install.

If there's a single line to take away: try? is a decision not to know. Sometimes that's right. On the file that contains your entire game's writing, it is not.

The story panels — which for one TestFlight build were not playing at all

The art was never low-resolution. It was thrown away on the way in.

Next complaint from the same tester (me): the characters looked soft. Blurry on the crew select, mushy in the comic panels.

I assumed this meant an art job — regenerate everyone, or run an upscaler over 96 portraits. Before doing either I measured, which turned out to be the whole story. Every portrait was 300 pixels tall (420 for the playable roadies). The game draws them at roughly 330 points, which on a 3x phone is about 990 pixels. So every face in the game was being blown up between 2.4x and 3.2x.

Nothing had been lost. The porting script that brought the cast over from the old browser game ends with fit(im, PANEL_HEIGHT) and PANEL_HEIGHT = 300. The sources were still sitting in the old game's archive at 1024px and up. The blur was one constant, set once, a month ago.

Re-cutting them got between 1.4x and 3.4x back. Two things I'd steal for any asset pipeline:

Never key a background you have to guess at. The cut-out tooling samples the corner colour and deletes everything like it. This cast wears grey shirts on grey backgrounds. It had already eaten two characters' shirts, one man's neck (which is why his t-shirt collar had read as a turtleneck for weeks) and a woman's trousers. The new rule: if a source still has opaque corners after keying the one colour nobody is wearing, throw the source away and use a different one.

Check the replacement against what it replaces. The "find the figure" heuristic keeps the largest connected island of artwork, which is normally the standing person. On one character's sheet the grid of expression heads all touched, merging into an island bigger than she was. It produced a perfectly-sized, perfectly-logged portrait that was a 4x3 wall of her own face. Now every candidate gets correlated against the image it would overwrite, and anything that doesn't look like the same person is refused.

28 new faces on the crew, all of them now at the resolution they were drawn at

Game Center is twenty lines of code and a wall of small forms

The code is genuinely trivial: an authenticateHandler at launch, GKLeaderboard.submitScore when a run ends, a view controller for the dashboard. An afternoon.

The rest is App Store Connect, and it says no in interesting ways. gameCenterDetails rejects every attribute you might reasonably send on create — the relationship to the app is the request. gameCenterAppVersions won't take enabled on create either; you make it, then immediately turn it on.

The good one: gameCenterAchievementImages has no sourceFileChecksum field. App screenshots do — same three-step upload dance, and that's where I'd copied the pattern from. So the bytes upload fine, the finalising PATCH 409s, and you get ten achievements that look completely finished with artwork permanently stuck in AWAITING_UPLOAD. Review would have bounced the lot. The repair step exists now purely because the create step skips anything that already exists, and therefore could never have fixed it.

One thing worth knowing before you test: there is no Game Center sandbox. Apple deleted it in iOS 9. Simulator, TestFlight and App Store builds all talk to the same production servers, so your test scores are real scores, visible to your real friends. Leaderboard data can be wiped before release. Achievement unlocks on your own Apple ID cannot.

External testers are not internal testers

Last one, and the most embarrassing. Internal TestFlight testers receive every build automatically the moment it finishes processing — you can't even attach a build to an internal group via the API; it returns a 422 telling you not to try.

External testers need three separate things to be true: "What to Test" written, the build attached to their group, and Beta App Review passed. Missing any one of them is not an error. The build simply never arrives.

Mine had none of the three. Six people had been sitting on an invite for days with nothing to install, and every API call I'd made had returned 200.

The thread

Every bug above failed silently, and every fix was the same shape: make the failure say something. A catch that prints the field. A validator that runs in a second. A check that the new art looks like the old art. A script that reports which of the three external-testing conditions isn't met.

None of that is clever. It's just the difference between a bug you find in a minute and a bug you find in a TestFlight build a week after launch.

Roadie Wars 5: Stage Climb is free on the App Store. No ads, no in-app purchases, nothing to buy, ever.