← All articles

The Soft-Lock That Wasn't a Soft-Lock: Hunting a Mid-Range Android Bug

How a pause flag, a background audio callback, and three mid-range Android devices turned a 'harmless' UI change into a session killer - and the checklist we now run before every soft launch.

Not every bug announces itself with a crash dump. The worst ones look like player mistakes: a menu that will not close, a run that will not resume, a phone that "just froze" until the OS killed the app. Last month we spent four days chasing one of those on a mid-range Android fleet - devices that passed our minimum-spec gate, shipped in soft launch, and then quietly ruined sessions for a slice of players who never filed a useful report.

This is the writeup we wish we had on day one. It is not about a clever algorithm. It is about how a tiny UI change, an audio callback, and Android lifecycle quirks combined into something that looked like a soft-lock and behaved like player churn.

What players saw

Reports came in messy:

  • "Game froze after I opened settings mid-run."
  • "Had to force close. Lost the run."
  • "Only happens sometimes. Fine on my friend's phone."

Store ratings did not spike overnight. Session length dipped. Resume-from-background rates got weird. Support tickets used the word freeze more than crash. That combination is a smell: something is stuck in a state machine, not exploding in native code.

We could not reproduce it on flagship test devices. Of course we could not. The bug preferred phones with:

  1. Aggressive OEM battery managers
  2. Slower main threads under thermal load
  3. Audio focus behavior that differed from Pixel defaults

If your QA rack is mostly clean Pixels and one iPhone, congratulations: you have optimized for a demographic that will not save you in soft launch.

The "harmless" change

Two weeks earlier we shipped a settings sheet that could open over an active run instead of kicking the player back to the hub. Product wanted fewer context switches. Design wanted the pause to feel instant. Engineering added a boolean:

gameplayPausedForUi = true

When the sheet closed, we set it back to false and called ResumeGameplay(). On paper that is three lines and a PR description that says "small UX win."

In practice we had three systems reading "paused":

  • The simulation tick (should freeze)
  • The input router (should ignore combat taps, still accept UI)
  • The audio mixer (should duck SFX, keep music policy consistent)

Only the first two were wired through the new flag. Audio kept its own pause token from an older pause menu path. That older path assumed pause always went through the full pause screen, which also unregistered a background audio focus listener.

The new settings sheet did not.

Why it only blew up on some Androids

On faster devices, the race almost never lost. Opening settings, ducking audio, and closing settings happened inside a frame budget where callbacks lined up in a friendly order.

On warmer mid-range SoCs, especially after ten minutes of combat particle spam, we saw this sequence:

  1. Player opens settings. gameplayPausedForUi = true. Sim stops.
  2. Audio focus listener still armed from the run.
  3. OEM kills or steals audio focus briefly (notification, thermal policy, competing media app).
  4. Our listener fires and sets an internal audioSuspended = true, then tries to pause gameplay "for safety."
  5. Player closes settings. UI path sets gameplayPausedForUi = false and calls ResumeGameplay().
  6. Resume early-outs because audioSuspended is still true and nobody clears it except the old pause-screen teardown.
  7. Screen looks alive. Buttons in the HUD are visible. Simulation is dead. Input for combat is gated. Settings can open again, which makes it feel even more like "the game is fine, something is wrong with this run."

Players mash. They background the app. They force quit. They leave a one-star review that says the game freezes.

From our analytics it looked like abandon. From their phone it felt broken.

How we finally caught it

We stopped asking "can we reproduce a freeze?" and started asking "can we reproduce a stuck pause token?"

That reframing mattered. We added a debug overlay (soft launch builds only) that printed the pause reasons as a bitfield:

  • UI_SHEET
  • FULL_PAUSE_MENU
  • AUDIO_FOCUS
  • APP_LIFECYCLE
  • TUTORIAL_LOCK

Then we dogfooded on the exact devices support kept naming. Within an hour we had a screenshot where the HUD looked normal and the bitfield still showed AUDIO_FOCUS after the sheet closed.

No mysticism. A sticky flag.

The fix (boring on purpose)

We did not invent a new pause architecture in a panic. We did three concrete things:

  1. One pause owner. A small PauseService now owns reasons as a set. Gameplay runs only when the set is empty. UI, audio, and lifecycle code add/remove reasons; they do not set a lone boolean.
  2. Sheet teardown mirrors menu teardown. Closing settings clears the same audio focus bookkeeping the old pause screen cleared. Duplicate cleanup is fine. Missing cleanup is not.
  3. Resume assertions in soft launch. If something calls ResumeGameplay() while reasons remain, we log the full set. In internal builds we flash a toast. Shipping builds stay quiet but keep telemetry.

The user-facing patch was small. The process change was the real patch.

Checklist we now run before soft launch

If you ship mobile games, steal this. It is shorter than a postmortem slide deck and more useful:

  1. List every reason the sim can stop. If two systems can pause without sharing a ledger, you have a future soft-lock.
  2. Open every overlay mid-run on mid-range hardware. Not once - after thermal soak. Ten minutes of real gameplay, then open settings, inventory, mail, rate prompts, whatever you shipped.
  3. Force audio focus loss while paused. Trigger a navigation prompt, alarm, or Bluetooth glitch. Close your UI. Confirm the run actually resumes.
  4. Background and foreground through every sticky state. Android will rearrange your assumptions for free.
  5. Print pause reasons in QA builds. If designers can see the bitfield, they will catch sticky states before players do.
  6. Do not trust "works on my Pixel." Keep at least two OEM mid-rangers in the dogfood pool, preferably the ugly popular ones.

What this taught us about "latest" tooling

It is tempting to answer every reliability scare with a new SDK, a new performance pack, or a new engine upgrade. Sometimes that is right. This time the advancement that mattered was not a package version - it was making pause state observable.

New Unity releases, new Android OS policies, and OEM audio quirks will keep introducing fresh edge cases. You cannot pre-read every device quirk. You can instrument the invariants that must never stick: pause, input gates, save locks, tutorial locks.

If a system can block progress, it needs a name, an owner, and a way to show up in a QA overlay.

Takeaway

The bug was not "Android is cursed." The bug was a UI convenience that reused half of an old pause path and trusted device speed to hide the seam. Mid-range phones refused to hide it.

If your soft launch metrics twitch and players say freeze while crash reporters stay calm, stop hunting explosions. Hunt sticky states. Put the reasons on screen. Then make sure every door that can pause the game also knows how to let it move again.

That is not glamorous work. It is how mobile sessions survive contact with real devices.

Next build

Building something for phones?

Pitch the game - we'll tell you what to prototype first, and which engine we'd actually use.