The QA department is a model
AI‑First Operations and Infrastructure
No QA team. No CI vendor. No on-call rotation. A cross-platform game ships on a harness that plays itself on real hardware every night, a model that reads what came back, and exactly one human decision at the end of it.
- 01:47DISPATCHA cron fires on a Mac mini. Nobody is awake.
- 01:47AUTHMints a GitHub App token — the default token would suppress the downstream event and silently break the loop.
- 01:48BUILD ×4Checkout with LFS, install, build the shared planner bundle, generate the Xcode project, compile.
- 02:05INSTALLdevicectl installs and launches on two phones physically tethered to the mini.
- 02:10CAMPAIGN150 minutes of real gameplay on real silicon — iPhone 12 and iPad Air 5, native first, then web.
- 04:40REPORTThe device uploads its own telemetry: memory by scene, texture attribution, ceremony coverage, every error.
- 04:45JETSAMCrash logs are pulled off the device whether or not the run passed, and archived for 14 days.
- 04:50AUTO-EVALCompletion fires a second workflow. A model reads the report and writes a structured verdict.
- 06:30SWEEPA scheduled job classifies what is novel and files it against the fingerprint registry.
- 07:00THE QUEUEA person wakes up to a triaged queue — and makes the only decision of the night.
Fig. 1 — The nightly soak, end to end. Every stage is a real workflow, script or collection in the repository. Times are the scheduled cron and observed job durations; the trace is ornamental.
Testing is a perception problem. Solve it once — give the model hands, eyes and instruments — and QA, performance, triage and release all become the same loop.
Nine stations, one human
Most studios have a pipeline that ends at “deploy” and a separate, human-shaped process that begins at “a player complained.” Here they are one track. A defect found by a machine at 04:00 and a defect reported by a person at noon enter the same queue, get the same fingerprint, and are ranked by the same rubric.
Everything on this track runs unattended except one station, and the automation is built to arrive there with the reasoning already done.
That station is meant to move. Today a person decides what a finding means and what to do about it. The direction of travel is that the machine also authors the fix and proves it against the replay seed that produced the failure — leaving exactly one human call at the end: ship it, or don’t. Not a person doing less thinking; a person doing the one piece of thinking that is expensive to get wrong.
Fig. 2 — the operations loop. Ice = unattended. Rose = the one station that requires a person — today. As the machine takes over authoring and validating the fix, that marker moves right, to the merge.
A harness that plays the game, not the screenShipped
The test driver is 8,743 lines living inside the shipping game. That is the whole trick, and it is worth being precise about why it matters.
A conventional mobile test rig — Appium, XCUITest, a Playwright device profile — stands outside the app and synthesises taps at screen coordinates. It cannot see why anything happened. It asserts on pixels, so it breaks when the art changes and passes when the art is right but the model is wrong.
Reveal Haven’s driver calls GameEngine.checkClick(x, y) directly, in scene coordinates — the exact method, with the exact signature, that a real player’s finger reaches. There is one other caller in the entire codebase: the canvas pointer handler. The driver is not simulating a player. It is entering the game through the same door.
Outside the app, looking in. Brittle to art, blind to state.
Inside the app, driving intent. Survives art changes, sees everything.
A bot that plays perfectly tests nothing real. So the planner draws a quota of human mistakes into every scene — clicks aimed just outside an object’s bounds, lore cards opened and left open long enough to read, thumbnail scrolls, a zoom schedule, and a real pinch through the live pointer pipeline.
It also survives being killed. Progress checkpoints to local storage every scene, and the pre-crash diagnostic trail is written to a key that is never rotated — so a device that gets jetsam-killed and relaunches still uploads the evidence of its own death.
Zero production footprint. The harness is behind a build-time constant and tree-shaken out. The App Store binary contains none of it.
Every run is a seedShipped
A test that can’t be repeated is an anecdote. So the harness overrides every source of randomness it uses — which scene, which object, when to fumble, how long to dwell — with one counted stream drawn from a single seed, and it writes that seed into the report.
Re-running with a prior report’s seed replays its exact path: same scenes, same objects, same misses, same order. A crash stops being a story reconstructed from a log and becomes an integer you can replay on demand.
That is what makes a fix checkable. Land the change, re-run the seed that produced the failure, and the answer is unambiguous — it reproduces or it doesn’t. Without it, “I think that’s fixed” is the most anyone can honestly claim, and the next occurrence is the only way to find out.
// reproduce a device crash exactly — nothing else changes VITE_SOAK_SEED=2718281828 npm run soak // arcs re-anchor on a derived seed → a crash in arc 5 replays without arcs 1–4
Fig. 3 — replay is a seed. Gesture jitter runs on a second, uncounted stream, deliberately kept off the campaign path so a wobble in one pinch can never shift which objects a run visits.
A Mac mini, two phones on a shelf, and zero CI spendShipped
Simulators do not get jetsam-killed. Thermal throttling, real memory pressure, real GPU drivers and the actual iOS memory manager only exist on actual hardware — which is exactly where a hidden-object game with 5504×3072 art plates falls over.
So the test lab is a single M4 Mac mini running three self-hosted GitHub Actions runners, with an iPhone 12 and an iPad Air 5 physically tethered to it. Two of the runners are labelled per device; the third does evaluation work.
runners
devices
runs
billed
Fig. 4 — GitHub bills only for hosted runners. A 240-minute on-device soak, run twice nightly across two devices, costs nothing in CI. The remaining spend is model tokens and the mini’s electricity.
The night the harness said the expert was wrong
The native build was being killed by iOS on an iPhone 12 at the first scene — a jetsam out-of-memory kill, peak resident memory 1,979 MB.
Instrumentation went in before any fix: a texture ledger reporting exactly what the GPU held at the moment of death. It reported 146 MB — the 5504×3072 backgrounds everyone suspected were a rounding error against a 1,979 MB process.
texture ledger at kill: total 146MB bg 64.5 cut 4.8 map 46.9 decor 30.0 largest 64.5MB ── i.e. textures are only 146 MB of a 1979 MB rss. ~1.8 GB is NON-texture. ──
Instrumentation output, verbatim from the investigation record.
The real cause was the curtain-call transition: a blur effect wrapping the full-size content in nested effect nodes, rasterised at 3× device scale — roughly 600 MB of offscreen buffer allocated during a decorative flourish. Four measured builds followed, each verified by a full on-device campaign.
Fig. 6 — peak resident memory, iPhone 12, measured on device across four builds. Net: 1,979 MB and a kill → ~320 MB steady, 483 MB peak, a clean 11-scene campaign with zero errors.
“Had we followed the doc and shipped background downscaling first, the app would STILL have jetsam’d at scene 1 — the blur, not the background, is the +600 MB that crosses the line. Phase-0 diagnostics-first earned its cost by catching this.”
A model looks. The work is showing it the exact frame.Shipped
A game is a visual artifact, and “does this look right?” is the one question no assertion answers. The industry reflex is pixel-diffing — snapshot the screen, compare to a golden image, fail over a tolerance. On a game that inverts itself: every art change is a diff, every intentional improvement a failure, and the suite decays into golden files nobody trusts.
So the judgment has to be a model’s. What decides whether that is affordable is how much you make it look at. Record a session from launch and hand over the video, and you pay for thousands of frames to reach the one that mattered — every run, forever.
The engineering therefore goes into the cheap half of the problem: navigate to the exact state and capture that single frame. One image, not an hour of footage. The same question gets answered for a fraction of the tokens, and the cost of asking it doesn’t grow with the length of the session.
Fig. 7 — jump to the state that matters, capture one frame, ask a model. The rig boots straight into a scene and drives real accessibility labels to reach states a from-the-menu script never gets to.
Jump to the exact state
A UI test boots via RH_START_SCENE straight into a scene and taps by accessibility label to reach settings sub-views and modal states no from-the-menu screenshot script can get to — attaching each frame to the test result. Navigating by intent means the same script yields a repeatable before/after of one specific screen, run after run.
See what’s too fast to see
Another capture throttles the network and screenshots every state transition of the loading sequence — in its own words, the moment that “otherwise flies by too fast to see.” The output is a film strip of an instant no reviewer could watch at speed, handed to something that reads all the frames at once.
capture tooling
captured deep screen
Telemetry rich enough to diagnose fromShipped
An evaluator is only as good as what it receives. “The app crashed” is not a diagnosis; it is a request for someone to go and reproduce it. So every surface — the nightly soak, a beta player’s phone, a person tapping Report an issue — returns a payload a model can actually reason over without asking a follow-up question, and does it while the always-on stream stays anonymous.
Session digest · every session
env · appVersion · buildNumber · platform deviceModel · gpuConstrained · testDriver sessionId · sessionDurationMs scenesVisited[] · adventureId errorCounts{} · warningCounts{} uniqueFingerprints[] perf { avgFps, p95FrameMs, droppedFrames } memory { peakTextures, peakVramMB, endVramMB }
Cheap, structured, 14-day retention. Four platform values: web · ios · ios-native · tvos-native.
Issue report · when a person speaks up
29 metadata fields ride along with the sentence the player typed: device model and memory, viewport and pixel ratio, locale, the exact scene and location, objects found out of total, time in scene, session duration, every completed scene, online state, connection type — plus an inline screenshot, the diagnostic log, and a serialised snapshot of durable save state.
Enough for an evaluator to reconstruct the moment without owning the device.
The native app originally sent none of this. Parity was a tracked defect.
The always-on telemetry is off until the player turns it on — a single “Share Analytics” switch in Settings, no second prompt. The consent is checked at the moment of send, so switching it off means the session in progress is never transmitted. There are no partial sends.
What does go is anonymous by construction: the only identifier is a random per-install UUID — never a name, an email, or an account — and it rides a 14-day TTL, so rows expire on their own. Analytics is never written into the durable save; the save is the player’s, the telemetry is a separate, disposable stream. A future opt-in that would link progress to an account is deliberately held in its own collection with its own consent, and can never reach back to de-anonymise a row already collected.
Shipping a 50 KB diagnostic log for every session would be wasteful and, at scale, hostile. So the digest always goes; the full log is attached only on escalation. Escalation is a deterministic ladder: a Tier-1 subsystem tag always qualifies, a Tier-2 tag samples at 50%, numeric thresholds catch avgFps < 45 or a memory breach, and 5% of clean sessions are kept as a baseline — because you cannot recognise abnormal without a sample of normal. A daily cap bounds the worst case.
The memory threshold is platform-relative — 150 MB against web texture memory, 1,200 MB against native resident memory — because the same number means different things on different engines.
Nothing is lost to a dead network. Reports queue on device and drain on reconnect, foreground or relaunch — never by polling. The submission timestamp is frozen when the player hit send, not when the network came back, and the client-generated ID is the document ID, so a retry can only ever overwrite itself.
Token cost should track novelty, not volume
The naive version of “AI reads your telemetry” is a model call per event. That cost grows with your success, which is exactly backwards — the better the product does, the more you pay to watch it.
Here the model is the last stage, not the first. Two deterministic passes run ahead of it and absorb almost everything, and — the important property — every newly catalogued defect is absorbed by the cheap stage forever after. The cost of watching goes down as the catalogue matures.
Fig. 9 — the triage funnel. Only the rose stage costs model tokens. A known crash recurring five hundred times is five hundred counter increments and zero model calls.
One queue. A machine and a person file into it the same way.Shipped
A soak run and a beta tester report into the same stream, are fingerprinted by the same function, and are ranked by the same rubric. Below is the real feedback stream — a tester’s sentence and the harness’s own report, minutes apart, indistinguishable as queue items.
08:32 MDT
08:27 MDT
10:05 MDT
09:02 MDT
21:57 MDT
Fig. 10 — the shared feedback stream, reproduced from the live admin dashboard. Tester names redacted; text otherwise verbatim.
The fingerprint
Both kinds of report collapse to the same stable identity. An error message is reduced to its leading words, stopping at the first token that looks variable — an id, a number, a filename — so “preload FAILED name=sfx_object_found” and every other asset’s version of it become one defect: Audio:error:preload_FAILED.
That id is the spine of everything downstream. It is what a recurrence increments rather than re-reports, what a fix is filed against, and what tells the evaluator whether it is looking at something new — which is exactly what keeps the token cost in §08 tracking novelty instead of volume. 24 defects are catalogued this way.
Ninety percent closed by machine. Ten percent handed over with the reasoning done.
Every completed soak run triggers an evaluation. A model reads the report, the crash log and the fingerprint registry, and emits a structured verdict — not prose. The driver validates it against a fixed enum and retries the model if it deviates: a classification, a confidence, a recommended action, a diagnosis, and evidence including the replay seed.
{ "classification": "new-regression", // clean · known-issue · new-regression · expected-content-gap · inconclusive
"confidence": "high", // high · medium · low
"recommendedAction": "propose-fix", // monitor · instrument-and-resoak · propose-fix · acknowledge
"fingerprintId": "Canvas:window.error:colorMatrixUniforms_null",
"diagnosis": "Exit-fade rAF mutated a ColorMatrixFilter after resetContainerState destroyed it.",
"evidence": { "seed": 2718281828, "keyMetrics": { "totalErrors": 15, "peakVramMB": 141.7 } } }Fig. 12 — a real verdict shape. Three of the five classifications auto-close. Only new-regression and inconclusive reach a person — and the routing is a pure function of the verdict, not a judgement call.
on record
authored
without a human
person
awaiting review
Fig. 13 — live counts, 2026-07-21. Fifty-two days of continuous operation at roughly two and a half evaluations a day. Of 128 verdicts, 116 closed themselves; twelve were dispositioned by hand as acknowledged, watching or dismissed.
This is the shape the whole system is built toward: the machine does the reading, the human does the deciding. Not because deciding is hard to automate, but because it is the step where being wrong is expensive and unrecoverable.
And the deciding keeps getting narrower. The machine already reads the report and names the defect; the next step is that it also writes the fix and proves it against the replay seed from §03 — so what reaches a person isn’t a finding to investigate but a change with evidence attached, and the only question left is whether it ships.
Seven and a half months, running the whole time
Dec 2025
TS · Swift · Python
tests
lifetime
test harness
evaluations
defects
measured on device
Fig. 14 — verified from the repository and live infrastructure, 2026-07-21. Two shipping renderers, four languages, five device profiles under nightly test, and 75,000 lines of written design record alongside the code.
The tell
A second adventure would cost a config file, not a department.
Shipping more content usually costs more people to check it — QA is a team, and teams don’t fork. What’s described here isn’t a team: it’s a harness that drives intent rather than pixels, a fingerprint function, a rubric, and a registry. None of those four things know they’re testing a hidden-object game, let alone which adventure.
The night shift has run for fifty-two days without being asked to. It reads its own output, files what it finds against a catalogue it maintains, and closes nine of every ten findings before anyone wakes up. The tenth arrives with a diagnosis, an evidence trail and a replay seed attached.
That last part is the whole design. Not a machine that decides — a machine that does every part of the work except deciding, and then hands over something a person can actually judge in a minute.
with no human
is required to make
minutes
