Move 89 of a soak run on a simulated iPhone 17 Pro. The game engine says the bot drew a seven-glyph chain worth +672. The app on screen played a five-glyph Bend worth +80. Same board, same finger path, 592 points gone. The trace had stopped at the cell where the finger was when the frame hitched.
Nascent is our iOS puzzle game. You drag chains of glass glyphs across a field, and the shapes you draw (lines, zigzags, bends, loops) are what score. The repository still carries its old working name, skilltro. Almost everything in the game rides on one gesture, so if the app misreads a drag, the player loses score and has no way of knowing why.


A bot with a shadow
Unit tests check the engine. They cannot tell you whether the engine and the screen still agree after the hundredth drag on a busy phone. So on 8 October we wrote a soak test: a bot that plays the real app the way a person does, with multi-point finger drags synthesised by the simulator, taps to skip animations, and taps on Begin and Continue on the cards between levels.
In a Debug build the app runs on a fixed seed and publishes its whole game state in a hidden element. The test holds a shadow copy of the game engine on the same seed, plays the same move on it, and after every move checks three things: the app’s game equals the shadow’s, the board on screen matches, and the score on screen matches. The first difference fails the run at that move, with the move number and the cells.
None of this ships. The Release app contains no bot, no soak code and no launch switches: a search of the built binary for their names found 0 matches.
Two runs do most of the work. Run A plays 25 levels from the start with the strongest bot we have. Run C kills and relaunches the app every five moves, some of them mid-animation, and checks that the autosave brings back the identical game. Ten adversarial tests do rude things on purpose: two-finger drags, rapid taps during an animation, rotating a portrait-only app, backgrounding it mid-move, switching to the largest accessibility text size mid-run, and taking Game Center offline at the end of a run.
SwiftUI was handing us the last position
The first pass, on 8 October, had already found a version of this bug. The field’s touch surface was a SwiftUI DragGesture, which delivers one sample per frame. When a frame stalled, the finger crossed several cells between samples, the next sample was not next to the end of the chain, and the step was refused. The chain stopped while the finger carried on. We fixed it by walking the straight line between samples in quarter-cell steps, so every cell crossed joined the chain in order.
That fix was not enough, and move 89 the next morning showed why. When the main thread is late, DragGesture does not just space its samples out. It reports only the newest finger position, and sometimes only the lift. A chain could lose its corners, its tail or the whole drag. Walking a straight line between two points cannot bring back a corner that was never reported.
Our first theory was wrong. The working guess was that an armed power-up was swallowing the drag. The readout disagreed: the tool slot was empty at every failure, and the screen recordings showed nothing armed. We only found the real cause because the shadow engine named the exact move and the exact cells, and a recording of that move showed the trace stopping short.
The fix, on 9 October, replaced the gesture with a small UIKit view. It passes every finger sample the system recorded to the game, in order, including the coalesced samples iOS batches up during a slow frame, and it traces to the lift’s own position before the chain plays. Five new tests failed before the fix and pass after it. Two of them go through real touches, using a Debug-only switch that blocks the main thread on every touch-down so a hitch happens on demand.
The simulators were hitching on 70 to 93% of frames during that soak, with a worst frame of about 1.1 seconds, because other builds were running on the same Mac. Those are not device numbers and we do not quote them as a frame rate. They did make a very good stress test. A person on an older phone with a busy main thread goes down the same code path, just less often.
The build nobody had compiled
The second find had nothing to do with touch. When we pointed the soak at a Release build, it would not build. Since a feature landed earlier that morning, every optimised build had crashed the Swift 6.3.3 compiler itself, in a pass called CopyPropagation, with “Found ownership error”. The trigger was an innocent-looking borrow of a score value inside a switch on an optional. Debug builds do not run that optimisation, so they were fine.
That meant no archive and no TestFlight build was possible, and nothing had told us. Our gate, the check every build has to pass before it goes anywhere, compiled Debug only, so it stayed green. We changed two things so the value is read once, outside the borrow, and the move plan’s steps and order stayed exactly as they were. From 9 October the gate builds Release as well.
The Release soak then took three more passes to go green, and both failures along the way were faults in our test code, not the app. The better of the two: the results table had been printing PASS for 14 Debug-only tests that failed at their first step in Release, because their summaries only recorded failures in the test code itself. They now skip in Release, and say so.
What the bot cannot tell us
Synthetic drags are perfect lines through cell centres. A human thumb is not, and the first bug shows why that matters. A simulator also has no Taptic Engine, renders in software and never gets warm, so haptics, real GPU timing and thermals are all untested here. The next step is a device run with Instruments watching for hitches over 25 levels, on the oldest hardware we can find.
The passing soak on 9 October played 1,145 moves across two simulated phones, in Debug and Release, with no divergence, no crash and no lost drag. The next thousand need a real thumb.



