PılPılı ← All posts

The 149-millisecond freeze on the first tap

Every action game in our catalogue froze for a seventh of a second on the player's first tap, and it had been shipping that way since the first game went live. In a runner, the first tap is the tap that starts the run and jumps — so every first run began with the game stopping dead, long enough to read the texture on the ground, and then jumping late.

Nobody caught it for weeks, because it happens exactly once per session. The second jump is clean. The hundredth is clean. Your muscle memory files the first one under "I mistimed it," and moves on.

PilPili is a browser-game arcade of small games, built by one person directing AI for about two hours a day.

The freeze turned out to be three separate causes hiding behind one symptom — each of which we "fixed" and declared done before the next one surfaced. If you build anything interactive for the web, at least one of these three is probably in your code right now.

Cause one: the audio engine bills its startup to your first tap

All our sound is generated in code, through the browser's audio API. The audio context was constructed lazily — the first time a sound was needed — which sounds tidy and thrifty. But constructing an audio context boots an audio thread, and we measured it at 130 to 150 milliseconds on a phone. The first sound is needed on the first tap. So the whole boot cost landed inside the tap handler of the most important tap in the game.

Measured precisely: 149 milliseconds with sound on, 26 muted, and clean 16-to-18-millisecond frames on every jump after. That signature — expensive once, cheap forever — is why it read as "the game hesitates before the first jump" rather than as a loading problem.

The fix rests on a fact about the API that is easy to miss: a browser requires a user gesture to start audio, but not to construct it. You can build the context at load time and it simply starts suspended; only resuming needs the gesture. So we build it up front — even when the game is muted, because otherwise the cost just moves to the moment the player unmutes.

The fix that wasn't, and the test that lied to us

Our first version built the context on an idle callback — politely deferred so the title screen wouldn't pay for it, with a timeout as a backstop. We verified the fix, reported it done, and it was not done.

The verification waited about 1.6 seconds after load before tapping — long enough to guarantee the deferred work had run. No player does that. A real finger lands about 200 milliseconds after the title appears, before any idle slot has come free, and the whole cost was back inside the tap, exactly as before. The test had been designed, without anyone meaning to, so that the thing under test always finished first.

A test that waits for the thing it is testing is not a test. That sentence went into our standing rules, and the context is now built synchronously at load, where nothing can race it.

Cause two: the first note also pays a toll

With construction moved, a smaller hitch remained on iPhones. It turns out constructing the context is not the only first-time cost: the first sound after resuming also starts the device's audio session, and that cost lands on whichever frame asks for it — the tap frame again.

The fix is almost nothing: the first sound of a session is deferred by one frame. Sixteen milliseconds later is inaudible to a human, it only ever happens once, and the tap handler stays clean.

Cause three: the oldest one, allocating scenery sixty times a second

The third cause had been shipping since our first week, in our two oldest games. Both built their sky gradient inside the draw loop — a fresh gradient object created sixty times per second, for a sky that only changes when day turns to night. Our own rules had said "zero allocation in the animation loop" from day one. The violations don't look like allocations; they look like drawing.

Allocation in a loop doesn't cost you on the frame it happens. It costs you later, when the garbage collector runs, as a stutter with no visible cause — which is why it hid so well behind the other two.

The scan that found those two had been run on two games, so we ran it on everything, and the full sweep found more than the pair we knew about: fifteen more per-frame allocations across five games — and the largest single one was invisible to the scan because it didn't look like a draw call. A day-night palette blend was recomputing fourteen arrays and fourteen strings every frame, about 1,680 objects per second, for a blend that sits unchanged at "day" or "night" for almost the entire run. It now recomputes only while the sky is actually transitioning, which is about two seconds per minute.

The checklist that survived

After the sweep: on a mid-range phone profile with the processor throttled four-fold, tapping 120 milliseconds after load with no grace period, the first-tap handler across all our games runs 0.7 to 8.2 milliseconds. The freeze is gone — measured gone, on the hostile path, not the polite one.

What we keep from this, and what I'd hand to anyone chasing a hitch:

Ask when it happens, not just what happens. Once per session means a first-time cost. Every time means a per-action cost. Irregular with no pattern means the garbage collector, which means an allocation.

Test the impatient path. Whatever delay your test waits before acting, a real user is faster. If your fix only holds after a warm-up, you have moved the problem to exactly the user who taps first.

One symptom can have several causes, and each fix will be declared victory. We reported this bug fixed three times. The only honest end state was a measurement on the worst path — throttled, immediate, cold — coming back clean. If we'd stopped at cause one, the freeze would be smaller and still there, and we'd have stopped looking.