WizusLabs Engineering · Craft

Pausing is the easy half

Stopping the simulation takes an afternoon. Getting every part of the game to agree that it is stopped — including the parts that quietly keep score — took us five separate rediscoveries in five different games in one week. This is what we found, including the one that reached players.

By WizusLabs Engineering · 2026-07-29 · ~8 min read

Freezing the simulation is the easy half. You gate the update loop, the hazards stop, the timer stops, and on your own screen the game looks properly paused — which is exactly why the remaining half goes unnoticed. The hard half is that everything which measures the game has to agree it is paused, and that the pause has to know who is holding it. In one week we rediscovered that shape in five of our own games, from five different directions, and filed it five times before anyone said out loud that it was one problem.

The courtesy is the settled part

What a game owes an interrupted player is not in dispute here. We have made that argument already: the instant the game is no longer in focus, nothing that can harm the player may keep running — stop the clock, freeze the hazards, halt the simulation. That is the courtesy, stated. This post is the other half of the job.

The difficulty has a specific cause. For the interruptions the platform owns — a call, a lock screen, an app switch — the operating system hands you a callback, and a competent app hangs its pause on that signal and is done. Nothing hands you a callback for one of my own widgets is now covering the board. A menu, a win overlay, a round briefing, a rewarded ad are things your own code did on purpose, so each is wired by hand, and each is a place a wire can be missing. Our games are free and ad-supported with a single optional Pro purchase that removes the ads, so an ad presentation is itself a surface the engine must hold a pause for.

The gate was right; its input was lying to it

The clearest example is from Iron Swarm, our wave-defence tank game. It awards mastery progression only for what the code calls a genuine run, and the test for a genuine run is an OR: you either reached wave two, or you stayed alive for sixty seconds. The second branch exists to be generous — plenty of honest runs end early and badly — and the gate’s own documentation comment said, in as many words, that its job was to block an instant-suicide farm loop.

That branch was measured with a wall clock: time of day at the run’s start against time of day at the run-end seam. A wall clock keeps accruing while the simulation is correctly frozen. So the exploit is: start a run, hold any pause for sixty seconds — the pause menu, the round briefing before you press START, a backgrounded app, a rewarded ad — then resume and die at wave zero or one. The waves branch fails. The seconds branch passes. The run banks progression. Time spent doing nothing counted as time spent playing; it required no skill, and the backgrounded variant ran unattended. The pause machinery worked perfectly throughout. The thing keeping score simply never asked it anything. Our own audit put the root cause in one line: a gameplay-semantic quantity sourced from wall-clock instead of simulation time, making all eleven pause reasons invisible to it.

That is the bitter part: a gate correctly designed for exactly the abuse it was written to stop, fed a number that did not mean what its name said. (The same audit found progression banking more than once per run — a different root cause, not this story.)

Two clocks across a held pause A schematic timeline. Both clocks start together at the run’s start and climb at the same rate. Across the held pause — the shaded interval — the simulation clock goes flat, because the world is no longer stepping, while the wall clock keeps climbing straight through and crosses the progression gate’s sixty-second threshold. After the resume both climb again, but the simulation clock ends the run far below the threshold. The vertical gap between the two lines at the end of the run is the exploit. the pause is held menu, briefing, ad, or backgrounded seconds credited to the run ↑ real time → the gate’s sixty-second threshold wall clock passes the gate simulation clock never gets there run starts pause begins resume death at wave 0–1
Both clocks start together. Across the held pause the simulation clock is flat — correctly, because the world is not stepping — while the wall clock walks straight through and crosses the gate’s threshold. The gap between the two lines is the whole exploit. Schematic, not measured data.

Why we did not just subtract the paused time

There is an obvious cheap fix, and rejecting it is the generalisable lesson. Keep the wall clock, track how long the game was paused, subtract. It works — and it is a promise to correctly bracket every paused interval forever, across overlapping reasons and the backgrounded case where the process may not even be scheduled to notice its own resume. Get one bracket wrong and the defect is back with a smaller footprint. Worse, it re-opens the hole the moment somebody adds a twelfth reason, which, as it happens, somebody did that week.

So the number changed its source instead. The run now carries a simulation clock accumulated from the engine’s own delta time, incremented inside update after every gameplay-tick early return — pause gate, hit-stop, async round rebuild — so it advances if and only if the world actually stepped that frame. That is correct for every pause reason that will ever exist, including the ones nobody has written yet. Nothing else moved; the defect was never in the gate’s design. When a quantity is wrong, ask whether the consumer is broken or the source is lying before you touch the consumer. Deriving a clock from the thing it measures is the same instinct as deterministic by design: make the artifact structurally incapable of disagreeing with reality.

One thing we will not dress up: we found this by reading code, not by playing it. The host was so contended that day that one static-analysis run took 937 seconds, so the exploit was not executed at audit time — and the audit said so in writing, because a green test is necessary but not sufficient for a timing defect, the standard we set for our automated review lane. Device verification came after the fix shipped: hold the pause menu past sixty seconds, resume, die at wave zero or one, bank nothing. That is the wrong way round, and it is what happened.

A visible overlay is a statement about painting, not about hit-testing

The same family produced a second defect in Sudoku by WizusLabs, and this one reached players. Two taps on an already-finished puzzle could resurrect it and restart its clock, inflating the solve time the player was looking at. We first assessed it as latent, because the win overlay is drawn on top of the board and the control in question sits underneath it. Then a hit-test probe at that control’s location, with the win overlay presented, came back reachesTopBar=true reachesWinOverlay=false. The overlay was not absorbing pointer events over the top bar. Live, not latent, and a player’s thumb was all it took.

Being on top is a fact about paint order. Absorbing input is a separate property something in the widget tree has to actually claim, and the two get conflated constantly because on your own screen they look identical: the thing is covered, so surely nothing behind it can be touched. It can. So we stopped trusting the eyeball and enumerated all 22 blocking overlays in the app for pointer-barrier safety, rather than waiting for a third instance to teach us the same thing.

Who is holding the pause?

The second family is a different problem that looks identical from outside, and the honest place to start is what went right. Iron Swarm’s five independent pause owners were collapsed into one arbiter holding a set of reasons rather than a boolean, which makes both classic resume bugs structurally impossible: the pause edge fires only when the set goes from empty to non-empty, and every release removes its own reason and then checks the set is empty before resuming. Releasing what you never acquired is a no-op; acquiring twice and releasing once cannot strand the game paused. Those two bugs ship with every hand-rolled pause flag, and they were gone by construction.

Then the crack. One reason value had two independent producers, and because the arbiter is a set rather than a per-owner counter, a reason is either held or not — so whichever producer released first released the other’s intent with it, resuming the simulation with a blocking surface still on screen. Reason sharing, not nesting depth, is what breaks a set-based arbiter. No currently reachable sequence had both producers live at once, so it was filed as an observation rather than a bug — and fixed anyway, with a one-reason-one-producer rule, because it is exactly the shape that bites the moment a new surface reuses an existing reason.

Embergrid is the mirror image, and it is the one with proof. There nesting depth is counted, and that choice was demonstrated rather than assumed: a bare acquire-and-release pair is not nesting-safe, because cleanup unwinds inner before outer — so the inner release empties a single-member owner set while the outer overlay is still visible, un-pausing the world underneath something the player is looking at. A deliberate mutation of the test suite reproduced exactly that, which is how we know the depth counter is load-bearing rather than decorative; the owner set is asserted at 10 observation points in a runtime gate that passes on a real device. Side by side the two are one discovery from opposite ends: a set fixes counting only if you also settle who may put things in it. Boardlore is a third variant in one sentence — its overlay-pause observer implements only the four route callbacks, so a visible non-route overlay bypasses it with the clock running behind, which is how it turned up in a guard nobody has seen fail.

What the correct version costs

So what does doing this properly cost? In NeuralSpark, our brain-training app, overlay pause is live across 84 hosts — and it got there through one universally-invoked seam, not 84 edits. That is the payoff, and it is only available if pause is a service with owners. A boolean each screen sets cannot be fixed centrally, because there is no centre.

The cost is real and worth naming: an owner set with depth counting and a producer rule per reason is meaningfully more machinery than paused = true, and it is machinery a new contributor has to learn before adding a menu. It earns that for one reason. A boolean makes correctness a property of every call site, and there is no such thing as a game whose call sites all stay correct. An arbiter makes it a property of one file — and the defects that survive, as ours did, stop being “somebody forgot” and become questions with answers: who produces this reason, and does anything keeping score still believe a different clock?

Freezing the simulation is the easy half. The hard half never announces itself, because a paused game looks paused — and the parts that were still running were the parts nobody was watching.

Notes

This is a first-hand account of defects in our own games, not a survey of anyone else’s. Every count here is read out of our own five repositories the week of 2026-07-29 — five games, eleven pause reasons at the time of the audit (the arbiter gained a twelfth within the week), five pause owners collapsed into one, the sixty-second gate branch, 22 blocking overlays enumerated, 10 owner-set observation points, 84 overlay-pause hosts, and one static-analysis run that took 937 seconds. They are facts about a moment in five codebases, not benchmarks, and they will drift the next time any of those games ships. There are no performance figures and no before-and-after quality metrics in this post, because we have none we would stand behind; inventing one to look rigorous would undercut the argument.

Two things stated plainly rather than softened. One of these defects was reachable by players — the finished-puzzle clock in Sudoku — with its reachability established by our own on-device hit-test probe after the fix shipped; we are not filing it as a near-miss. And the progression exploit was found by reading code, not by exploiting it: the run on a device came after the fix shipped, which is the wrong order and is recorded that way in the project’s own audit. Our games are free and ad-supported, with a single optional Pro purchase that removes the ads.

Keep reading: all posts on the WizusLabs Engineering blog.

← Back to the Blog