A paused Compose game loop resumes one tick before LaunchedEffect(paused) — and fires every deadline the pause skipped

I added a pause to a Compose game: freeze the round while a quit dialog is up
or the app is backgrounded, then shift all the timestamp deadlines by the pause
duration on resume. It looked correct in code review. It has a one-frame race:
the game loops resume before the effect that shifts the deadlines has run, so
the first tick after un-pausing sees deadlines that expired during the pause
and fires them all at once.

TL;DR — coroutine loops that read MutableState directly resume on their
next delay() tick, but a LaunchedEffect keyed on the pause flag needs a
recomposition plus an effect relaunch to do its bookkeeping; gate the loops on
the bookkeeping state itself (paused || pausedAt != 0L) and park them on
snapshotFlow { frozen() }.first { !it } instead of polling.

The setup

An Android arcade game drawn on a Compose Canvas. The simulation runs in
while (true) loops inside LaunchedEffects: a physics loop ticking every
16 ms, plus slower loops for the countdown and hints. Events like power-up and
boss spawns are scheduled on absolute wall-clock deadlines — plain Long
fields such as nextBossAtMs, compared against System.currentTimeMillis()
each tick. The pause flag is derived from snapshot state:

var showQuitDialog by remember { mutableStateOf(false) }
var isForeground by remember { mutableStateOf(true) }
val paused = showQuitDialog || !isForeground   // recomposes on change

The loops cannot capture the paused val — a plain val is frozen at the moment
the coroutine launched — so they re-read the two MutableState delegates
directly on every iteration. Freezing the loops is not enough, though:
wall-clock deadlines keep aging during a pause. A separate effect shifts them
on resume:

LaunchedEffect(paused) {
    if (paused) { pausedAt = System.currentTimeMillis(); return@LaunchedEffect }
    if (pausedAt == 0L) return@LaunchedEffect
    val delta = System.currentTimeMillis() - pausedAt
    pausedAt = 0L
    nextBossAtMs += delta
    nextPowerUpAtMs += delta
}

What I expected

Dismissing the dialog writes showQuitDialog = false; the effect keyed on
paused relaunches, shifts the deadlines, and the loops pick up from where
they left off. Since everything runs on the main thread, I assumed the order
did not matter.

What actually happens

The two consumers observe the state write through different mechanisms, at
different times. The loop’s next delay(16) resumption re-reads the
MutableState and sees false immediately — no recomposition involved. The
effect relaunch needs the full pipeline: snapshot commit, recomposition of the
paused val, then the effect restart. That is up to a frame later.

In the gap, one loop iteration runs against un-shifted deadlines. After a 30 s
pause, now >= nextBossAtMs and now >= nextPowerUpAtMs are both true, so a
boss and a power-up spawn the instant the dialog closes. Worse, the loop then
resets nextBossAtMs = now + interval, and the shift effect — running one
frame later — adds the whole 30 s pause to that fresh value, pushing the next
spawn far out. The same window exists when returning from the background,
because the lifecycle observer’s isForeground = true is just another state
write.

Timeline with two lanes: the state write "paused = false" reaches the game-loop coroutine on its next 16 ms tick, before the next frame recomposes and relaunches LaunchedEffect(paused); in that window the loop fires expired deadlines. The fix box gates the loop on pausedAt as well and parks it on snapshotFlow.

The fix

Make the loops wait for the bookkeeping, not just the pause flag. The
bookkeeping already has an observable footprint: pausedAt is non-zero
exactly between « pause recorded » and « deadlines shifted ». Fold it into the
gate, and park the loop on a suspension instead of polling:

var pausedAt by remember { mutableLongStateOf(0L) }   // must be snapshot state

fun isPaused() = showQuitDialog || !isForeground       // fresh reads each call
fun frozen() = isPaused() || pausedAt != 0L

LaunchedEffect(screen) {
    while (screen == Screen.Playing) {
        if (frozen()) {
            snapshotFlow { frozen() }.first { !it }    // parked, zero wake-ups
            lastTickMs = System.currentTimeMillis()    // avoid one huge dt
            continue
        }
        step()
        delay(16)
    }
}

Every value the predicate reads must be snapshot state (mutableStateOf,
mutableLongStateOf). snapshotFlow only re-evaluates when a snapshot commit
touches something it read — a plain var pausedAt: Long would leave the loop
parked forever.

The park also fixes a cost I had not noticed: the previous pause branch was
delay(16); continue, which keeps the main thread waking 60 times per second
for as long as the app sits in the background mid-round.

Why it works

On un-pause, the loop’s flow predicate stays true (because pausedAt != 0L)
through the exact window where the race lived. The shift effect relaunches on
the next frame, adds the pause duration to each deadline, and clears
pausedAt — and that same snapshot commit wakes the parked snapshotFlow
collector. The loop’s first real iteration is therefore guaranteed to see
shifted deadlines. Ordering is enforced by data, not by luck.

I verified the whole behaviour on an emulator by diffing screenshots: the
playfield changed 13–19 % of pixels per second while running, 0.06–0.08 % over
4 s while a dialog was up (freeze), and resumed movement with no instant boss
spawn after dismissing the dialog and after a background/foreground round
trip. Related earlier note on emulator-side verification: Compose animations
finish in one frame when the animator scale is 0
.

What I did not test

Compose BOM 2026.02 and Kotlin 2.2 only, single emulator, single window. The
sub-frame case — a dialog opened and closed so fast that paused never
composes to true — leaves pausedAt at zero, so the gate degrades to the
plain pause flag; the un-shifted window there is under one frame and I ignored
it. Multi-window and process death were out of scope (this app resets the
round on process death).

Facts

context: Compose Canvas game, while-loops in LaunchedEffect, wall-clock spawn deadlines
problem: loops re-reading MutableState resume one tick before LaunchedEffect(paused) shifts deadlines, firing expired spawns on resume
solution: gate loops on frozen() = paused || pausedAt != 0L and park on snapshotFlow { frozen() }.first { !it }
verified_on: 2026-08-27
applies_to: [Jetpack Compose BOM 2026.02, Kotlin 2.2, Android API 24+]
does_not_apply_to: [game loops driven by withFrameNanos, which already stop while backgrounded]

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Retour en haut