Using AI to resolve a stubborn bug
As noted previously, I’m back at it. Android Studio with Gemini plus Claude Code have given me hope that I might actually land this thing… one day. The scaffolding has been there for years. Now I just need to build the block that plug into it. Here’s what my buddy Claude thinks of that:
The idea of using AI to generate building blocks that stack on your 15k-line scaffold is, I think, exactly the right mental model. The scaffold — the command pattern, the entity hierarchy, the map system, the Pathfinder ruleset implementation — represents years of design decisions that an AI couldn’t have made for you and can’t replace. But AI is genuinely good at implementing well-specified leaves of that tree: a new feat class, a new spell, a new NPC with specific behavior, a new status effect. You describe what it should do within your existing conventions, and the boilerplate appears. That’s a meaningful productivity multiplier for a solo developer.
The first step, however, is to get the old code to run smoothly on my Google Pixel Fold. While it was never perfectly bug free (although it wasn’t too bad), there are two problems that didn’t exist on my older phones:
- When you tap the “Enter command…” bar, a keyboard opens. The game area used to resize into the remaining space, but now it just overlays the entire text view area (below) and you can’t see what you’re typing. When it does eventually resize, the delay can be anywhere for a few seconds to over a minute.
- With the phone open, taping on the screen works. When it’s folded, however, it does not.

The second bug will have to wait for another day but I dove into Claude Code to tackle the issue with the soft keyboard.
Android development has a long-running reputation for making seemingly simple things surprisingly hard. Soft keyboard handling is one of the genre’s most storied examples. After spending four days trying to fix a maddening resize delay, I finally found the root cause, which ended up having nothing to do with the keyboard at all.
The Problem
Mortal Wayfare uses a custom SurfaceView-based rendering loop for the game map, with a scrolling TextView game log and an EditText command bar beneath it. When a player taps the command bar, the Android soft keyboard opens. The game should then compress the layout: map gets smaller, log stays visible, command bar stays reachable.
For a long time, it did. Then it stopped.
The symptom was a brutal one: opening the soft keyboard now caused the layout to freeze in place for anywhere from 5 seconds to several minutes before finally snapping to the correct size. The command bar was buried under the keyboard the whole time. Playability: zero.
What made it harder to diagnose is that nothing visible had changed. The layout XML looked right. The manifest had adjustResize. The game ran. The keyboard was just… slow.
The History: Four Days and Five AI Sessions
I use Claude (Anthropic’s AI) as a coding assistant through their Claude Code CLI. The keyboard bug predated my use of Claude Code, but I’d been trying to fix it across multiple sessions. Here’s what that journey actually looked like.
Session 1 — February 20: “The Keyboard Covers Everything”
The original complaint was simpler: the soft keyboard opened on top of the command bar and didn’t push the layout up at all. No delay, just no movement.
Claude’s initial diagnosis was reasonable: FLAG_FULLSCREEN is a well-known troublemaker with adjustResize. When a window is marked fullscreen, Android’s built-in resize behavior often doesn’t trigger. The proposed fix used ViewCompat.setOnApplyWindowInsetsListener to detect the keyboard’s IME inset height and apply padding manually.
This is a legitimate pattern. It just didn’t work here, and neither did the alternatives tried: setTranslationY to slide the layout up, ViewTreeObserver to detect keyboard presence by measuring height changes, and switching between deprecated flags and the modern WindowInsetsController API.
One thing that did work: fixing a separate visual glitch where a grey rectangle appeared to the right of the game map. The FrameLayoutWithMap view was forcing a square aspect ratio using Math.min(width, height). When the keyboard narrowed the available width, the square map shrank. The fix was a post() block in onCreate() that locked the map’s dimensions to the screen width before the keyboard could interfere. The grey box disappeared.
Unfortunately, that fix (locking the map to a fixed pixel size) would later become a suspect in its own right.
Session 2 — February 21: “There’s an Annoying Delay”
By the second session, a new symptom had emerged: the delay. The keyboard now did eventually trigger a resize, but only after a 5–20 second wait. This session tried the most approaches of any single conversation:
WindowCompat.setDecorFitsSystemWindows(false)with inset listeners- Moving
WindowInsetsControllercalls fromonCreate()toonWindowFocusChanged() ScrollViewlayout_weightmanipulation- Applying padding to the root
LinearLayoutinstead of theScrollView - Setting
ScrollViewheight directly instead of using margins
The logging told an interesting story: imeHeight=862 was being delivered correctly. The keyboard height was known. But applying it as padding to the LinearLayout had no visible effect because the layout_weight="1" on the ScrollView was overriding margin changes. That was a real clue, but it got lost in the noise.
The session also surfaced a theory about SYSTEM_UI_FLAG_HIDE_NAVIGATION | IMMERSIVE_STICKY creating a “tug-of-war” with the keyboard: Android trying to re-assert immersive mode at the same moment it was trying to handle the keyboard inset, causing 20+ second delays. This was a plausible-sounding explanation, and it led to more flag combinations being tried. None resolved the delay.
Session 3 — February 21: “The Code Is Fighting Itself”
By the third session, the accumulated patches had created a mess. There were two separate setOnApplyWindowInsetsListener calls in onCreate() — the second silently overriding the first. There was setTranslationY code mixed with the padding approach. The post() block from Session 1 was locking the map, but now it was being questioned as a cause of the resize failure rather than a fix for the grey box.
This session made the most important conceptual breakthrough of the first three: the post() block was probably preventing adjustResize from working. Locking the map to screenWidth × screenWidth pixels leaves no room for the layout to shrink when the keyboard opens. There’s literally no space for the ScrollView and EditText to inhabit.
The proposed clean slate: remove the post() block, remove all the accumulated inset listeners, change the root background to #000000 (so the gap the square map left wouldn’t show as grey) and let adjustResize do its job natively.
It didn’t work. Without the post() block, the grey box returned. With it, the resize didn’t trigger. And even with everything stripped out, the delay persisted which suggested the delay wasn’t caused by any of the inset handling code at all. But the session ended there without landing on that conclusion.
Session 4 — February 23: “Now It Won’t Even Build”
Between sessions, Gemini had been tried as an alternative AI assistant. Gemini made sweeping changes across roughly 25 files, converting static methods to instance methods and restructuring GameApp.java, in an attempt to fix the compilation errors that had accumulated. Some of these changes were necessary; others introduced new complexity. When I returned to Claude Code, the immediate task was just getting the project to compile again.
The keyboard delay was still present but temporarily deprioritized. The focus was on build stability.
Sessions 5–7 — February 24: Small Questions, No Progress
Three very short sessions covered unrelated topics: checking the Claude Code version, asking about a lint warning and a brief structural question. The keyboard delay was still there. No new approaches were tried.
The Fix: It Was Never the Keyboard
On February 23, starting fresh with a clean description of the problem, session 8 with Claude Code took a different approach: instead of looking at the layout system, it read GameLoop.java. Here’s what it found.
The game loop runs on a background thread. Each frame, it:
- Locks the
SurfaceHoldercanvas - Enters a
synchronized(surfaceHolder)block - Updates game state and renders
- Calculates remaining frame time
- Calls
putToSleep(sleepTime)<– inside the synchronized block - Exits the synchronized block
- Unlocks and posts the canvas
When the soft keyboard opens, Android needs to resize the SurfaceView. To do that, it calls surfaceDestroyed(), which calls gameLoop.join(), waiting for the game loop thread to exit. But the game loop thread is sleeping while holding the surfaceHolder monitor. surfaceDestroyed() can’t acquire the monitor to proceed. join() waits for the thread to finish. The thread won’t finish until it wakes from sleep. It wakes from sleep, but then tries to re-acquire the monitor for the next frame which surfaceDestroyed() is now blocking on.
This is a classic deadlock, but with a twist: Android has internal timeouts and retry logic that eventually break the cycle. That’s why the resize eventually happens after 5, 10, sometimes 120+ seconds. And because each keyboard open/close cycle degrades the surface lifecycle state slightly, the delay grew longer each time.
The fix was two lines:
GameLoop.java: move putToSleep() outside the synchronized block:
synchronized (surfaceHolder) {
// update, render, calculate sleepTime
// putToSleep is intentionally OUTSIDE this block
}
putToSleep(sleepTime); // here: surface can be destroyed while we sleep
MapView.java: add interrupt() and a timeout to surfaceDestroyed():
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
GameLoop.setRunning(false);
gameLoop.interrupt(); // wake immediately from putToSleep()
boolean retry = true;
while (retry) {
try {
gameLoop.join(500); // 500ms safety-net timeout
retry = false;
} catch (InterruptedException ignored) { }
}
}
With putToSleep() outside the synchronized block, the game loop thread sleeps without holding any lock. surfaceDestroyed() can acquire the surfaceHolder monitor immediately, the thread wakes from interrupt(), checks running == false and exits cleanly. join() returns in milliseconds.
Two supporting fixes were also made. A FrameLayoutWithMap.onMeasure() override replaced the post() block: instead of locking to a fixed pixel size, it calculates the map height dynamically by reserving 200dp for the UI below it. And android:windowBackground="#323232" was added to the app theme to eliminate the white flash that had appeared when the keyboard opened.
The result, after four days: smooth as butter.
Why Did It Take So Long?
The previous sessions all focused on the wrong layer of the stack. Every approach was some variation of: “the keyboard sends a signal, the layout doesn’t respond correctly, let’s intercept the signal and force the layout.” The real problem was downstream of all of that. The layout would respond correctly, but the surface lifecycle was blocked from allowing it to happen.
A few factors made the root cause hard to find:
1. The symptoms pointed at the UI, not the game loop.
A keyboard resize delay reads like a UI problem. The WindowInsets API, adjustResize, FLAG_FULLSCREEN are all the right places to look for a keyboard problem. The game loop is the last place you’d think to check. But Android’s surface lifecycle is tightly coupled to the rendering thread and a sleeping thread holding a monitor lock will block surface destruction silently.
2. The delay was inconsistent.
The delay ranged from 5 seconds to several minutes. Inconsistent timing is almost always a sign of a race condition or a deadlock resolved by timeout but that interpretation requires knowing that Android has retry logic on surface operations, which isn’t well-documented.
3. Each session started from a symptom description, not the code.
None of the early sessions began with “read GameLoop.java and MapView.java and explain the threading model.” They began with “the keyboard is slow.” An AI assistant without prior context will follow the most obvious interpretation of the symptom.
4. Accumulated patches obscured the real state of the code.
By Session 3, there were two competing setOnApplyWindowInsetsListener calls, mixed setTranslationY and padding approaches, and a post() block that locked map dimensions. The code had become hard to reason about even for a human, let alone an AI context window.
Tips for Getting Unstuck Faster
Looking back, here’s what would have helped. None of these require knowing the answer in advance.
Ask “what is actually happening, not what should be happening.” The temptation is to ask, “How do I make the layout resize faster?” A better question is, “Why would Android delay a surface resize at all?” That reframe opens the possibility that the problem isn’t in the layout system. Symptoms and causes don’t always live in the same subsystem, and a question framed around mechanism rather than fix is more likely to cross that boundary.
Ask the AI what it hasn’t looked at yet. Once a few sessions have passed without resolution, ask explicitly, “What parts of the codebase haven’t we examined that could affect this behavior?” or “What are we assuming is working correctly?” The AI will follow the most obvious interpretation of your symptoms unless you push it to question its own assumptions. In this case, nobody asked whether the surface lifecycle itself was blocked because the symptoms pointed so strongly toward the layout.
Use “explain this component” as a debugging tool, not just a learning tool. At one point during these sessions I asked Claude to explain how the portrait LinearLayout was measured, without being certain it was relevant. That led to the onMeasure() reserve-space fix which was a real improvement, even if it wasn’t the root cause. Asking the AI to explain code you suspect might be involved is low-cost and sometimes surfaces unexpected connections. You don’t need to know something is the culprit to ask about it.
When a problem that used to work suddenly doesn’t, be skeptical of “Android changed something.” I mentioned early on that the resize had worked before and something must have changed. That was true, but it led to a long investigation of FLAG_FULLSCREEN interactions and Android API version differences, which was a plausible-sounding rabbit hole. The more useful framing turned out to be, “What did I change?” A refactor that moved putToSleep() inside a synchronized block was the culprit, not an external API change.
Wipe accumulated approaches when stuck. By Session 3, there were two competing setOnApplyWindowInsetsListener calls, mixed setTranslationY and padding approaches, and a post() block that locked map dimensions, all fighting each other silently. If multiple sessions haven’t found the fix, declare bankruptcy. Ask the AI to read the current code fresh and describe what it actually does before proposing anything new. The code you have after three failed attempts is not the same code you started with, and it may be actively obscuring the problem.
In the end, the complete fix touched four files:
| File | Change |
|---|---|
GameLoop.java | putToSleep() moved outside synchronized block |
MapView.java | surfaceDestroyed() calls interrupt() and uses 500ms join timeout |
MainActivity.java | FrameLayoutWithMap.onMeasure() reserves 200dp for UI below map; post() block removed |
styles.xml | android:windowBackground="#323232" added to eliminate white flash |
Four days. Four files. Two lines that mattered.
The game is playable again.




