Reveal Haven
← TechOperations & Infrastructure

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.

Fig. 1 — one unattended night, 01:47 → 07:00 MST
DISPATCH01:47AUTH01:47BUILD ×401:48INSTALL02:05CAMPAIGN02:10REPORT04:40JETSAM04:45AUTO-EVAL04:50SWEEP06:30THE QUEUE07:00
01:47A cron fires on a Mac mini. Nobody is awake.
  1. 01:47
    DISPATCH
    A cron fires on a Mac mini. Nobody is awake.
  2. 01:47
    AUTH
    Mints a GitHub App token — the default token would suppress the downstream event and silently break the loop.
  3. 01:48
    BUILD ×4
    Checkout with LFS, install, build the shared planner bundle, generate the Xcode project, compile.
  4. 02:05
    INSTALL
    devicectl installs and launches on two phones physically tethered to the mini.
  5. 02:10
    CAMPAIGN
    150 minutes of real gameplay on real silicon — iPhone 12 and iPad Air 5, native first, then web.
  6. 04:40
    REPORT
    The device uploads its own telemetry: memory by scene, texture attribution, ceremony coverage, every error.
  7. 04:45
    JETSAM
    Crash logs are pulled off the device whether or not the run passed, and archived for 14 days.
  8. 04:50
    AUTO-EVAL
    Completion fires a second workflow. A model reads the report and writes a structured verdict.
  9. 06:30
    SWEEP
    A scheduled job classifies what is novel and files it against the fingerprint registry.
  10. 07:00
    THE QUEUE
    A 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.

01 The 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.

THE FIX RE-ENTERS AT BUILDBUILDlint · typecheck · 4,646 testsTEST8 device profiles · WebKitSHIPgated deploy · signed archiveOBSERVEsoak · players · crash logsFINGERPRINTsubsystem : level : slugTIERimpact × reachEVALUATEmodel writes a verdictDECIDEpriority + disposition◆ HUMANFIXbranch · test-first · PR

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.

02 Hands

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.

Bolt-on driver · Appium / XCUITest
Scripttap(412, 880)
OSsynthetic touch event
Viewhit-test the view hierarchy
Rendereropaque canvas — one big rectangle
Engineinvisible
Asserton pixels

Outside the app, looking in. Brittle to art, blind to state.

In-engine driver · Reveal Haven
Plannerseeded intent: “find object 3”
Engineengine.checkClick(x, y)  ·  scene coords
Shared↑ the only other caller is the real pointer handler
Stateengine.getState() → objects, foundCount, isComplete
Singletonsreal SceneManager · ProgressManager · SoundManager
Asserton the model

Inside the app, driving intent. Survives art changes, sees everything.

It plays badly on purpose

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.

03 Determinism

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.

04 The night shift

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.

3
Self-hosted
runners
2
Tethered
devices
11
Workflows
1,960
Lifetime
runs
$0
Actions minutes
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.

05 Proof

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.

Baselinebefore the investigation
1,979 MB
JETSAM · killed at scene 1
Snapshot-based blurcurtain-call transition
530 MB
survives · crashed later
Main-thread eventsoff-main race removed
533 MB
full 11-scene campaign · 0 errors
Device-tier downscaleart sized to the handset
483 MB
full campaign · 0 errors

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.

The counterfactual, recorded at the time

“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.”

06 Eyes

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.

BOOTRH_START_SCENEopens straight into thetarget scene — skip the gameNAVIGATEtap by a11y labelreach a modal the menuflow can't script toCAPTUREone frame, attachedexactly this state —nothing before or afterTHE MODELreads the frame“does this look right?”answered in wordsONE FRAME OF THE EXACT STATE — NOT RECORD-FROM-LAUNCH AND WATCH HOURS OF VIDEOSAME RIG STEPS FAST TRANSITIONS FRAME-BY-FRAME · AND PAIRS WEB ↔ NATIVE ON DEMAND

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.

~1,100
Lines of
capture tooling
1
Command to a
captured deep screen
07 Instruments

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.

Privacy is the constraint this is designed around, not a footnote

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.

Two layers, because logs are expensive

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.

08 Economics

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.

T0T0 · ROLLUP0 model callsEVERY SESSIONPure function. Buckets by day, build, device, adventure.Counts errors, warnings, FPS and memory breaches.T1T1 · FINGERPRINT MATCH0 model callsONLY SESSIONS THAT ERROREDSet membership against the known-issue catalogue.Known → increment a counter. That is the entire step.T2T2 · MODEL EVALUATIONTOKENS SPENT HERENOVEL ∧ ESCALATEDA defect nobody has catalogued, on a session thatkept its full diagnostic log.EVERY DEFECT THE MODEL NAMES IS ABSORBED BY T1 FOREVER AFTER — THE FUNNEL GETS CHEAPER AS IT LEARNS

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.

09 Judgement

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.

ResolvediOSBeta tester
Clicked this item and it gave me credit but didn’t disappear from the map, only the bottom drawer
Scene: Somnathpur Temple
Mar 14, 2026
08:32 MDT
ResolvediOSBeta tester
The paintings don’t match!
Scene: Shakthan Thampuran Palace
Mar 14, 2026
08:27 MDT
ResolvedTestDriverTestDriver
Soak Test Report — 3 iteration(s), 30 scenes, 0 error(s) · Memory: textures 28→2 (peak 37), VRAM 103.4→0MB (peak 110.5MB) · Perf: avg 60.0 fps, worst P95 17.0ms, dropped 594 frames
Mar 13, 2026
10:05 MDT
ResolvediOSBeta tester
Audio was missing this morning
Mar 13, 2026
09:02 MDT
ResolvedTestDriverTestDriver
Soak Test Report — 10 iteration(s), 100 scenes, 0 error(s) · Memory: textures 34→2 (peak 44), VRAM 104→0MB (peak 111.7MB) · Perf: avg 60.0 fps, worst P95 18.0ms, dropped 2034 frames
Mar 12, 2026
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.

10 The gate

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.

128
Evaluations
on record
100%
Machine-
authored
90.6%
Auto-closed
without a human
12
Reached a
person
0
Currently
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.

Used in anger

Seven and a half months, running the whole time

2,444
Commits since
Dec 2025
327k
Lines of code
TS · Swift · Python
4,646
Automated
tests
1,960
CI runs
lifetime
8,743
Lines of
test harness
128
Machine
evaluations
24
Catalogued
defects
1,979→483
MB peak memory,
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.

90.6%
Of findings closed
with no human
1
Decision a person
is required to make
$0
Spent on CI
minutes