Everything below follows from four facts, and every one of them is checkable.
1. The client is a live service. Blizzard's EULA bans software that allows automated control of the game, and the community Code of Conduct puts it plainly: "using third-party programs to automate any facet of the game ... is considered cheating." There is no reading of that where an agent pressing keys on a live character is permitted. That is a decision for the account owner, not a design detail, and this post describes it as such.
2. The client hides itself from software. The Metal client exposes no usable accessibility tree, so the frame is pixels or nothing. And the modern addon API has secret values: in combat, addon code cannot read enemy health, casts, auras, or cooldown remaining. A rotation helper addon literally cannot compute a rotation any more. What survives is display: values can be shown, not computed on.
3. The decision clock is not a frame clock. A global cooldown is 1.5 s (0.75 s at maximum haste), so on-GCD rotation decisions run at 0.67–1.33 per second. Off-GCD actions — interrupts, defensive cooldowns, moving out of a ground effect — are not bounded by that at all and want human reaction time.
4. Vision inference is local. Frames never leave the machine; only text may. We measured what a cloud vision round-trip costs, and it was ~20 s per frame. That is not a budget, it is a different activity.
The first build was deliberately dumb: capture the game window, ask a local vision model for a JSON description of the screen, diff that description against the previous one, and speak only when something changed. Six stages, one process, no framework.
What it got right in a live 21-minute session: target announcements ("Target Stitches, level 35, elite"), player /say lines verbatim, and silence 91.6% of the time. What it got wrong: 25 of its 35 spoken lines were zone names, because the minimap names every subzone. The lesson is not about models. The gate is the product. Every failure happened after the model, in the logic that decided something had changed.
The second machine renders at 1080p, and the obvious assumption is that a quarter of the pixels means a quarter of the work. It does not work out that way.
| Input | Vision tokens | Read | What happened |
|---|---|---|---|
| 4096 × 2304 (current) | 1015 | 0.68 s | Digits slipped (0/12 read as 0/4) |
| 1920 × 1080 (second box) | 1015 | 0.78 s | Quest counts correct |
| 1280 × 720 | 625 | 0.62 s | Zone misread, brackets dropped — the floor |
| 960 × 540 | 361 | 0.80 s | Invented a completely different character |
The server clamps every image to about a thousand vision tokens, so 4K and 1080p cost the same and run at the same speed. What 1080p actually buys is the same token budget spread over 4.5× fewer pixels: sharper glyphs per token, which is exactly the accuracy problem we had. Payload drops from 14.5 MB to 3.6 MB, so capture gets cheaper too. Below 1280 wide the model starts inventing, which is a hard floor worth knowing before building on it.
:30006 with thinking off. Token counts come from the response's own usage field.This is the part I expected to be encouraging and turned out to be clarifying instead. A dozen serious projects have tried to put an AI in a game. The ones that work in real time are not doing what a prompt-driven VLM does.
| Project | Interface | Architecture | What it actually achieved |
|---|---|---|---|
| Voyager (2023) | Mineflayer JS API — privileged, text only | GPT-4 with an automatic curriculum, a skill library of executable code retrieved by embedding, and iterative prompting with self-verification | 3.3× more items, 2.3× distance, tech-tree milestones up to 15.3× faster than prior work; diamond tools in 1 of 3 runs |
| Ghost in the Minecraft (2023) | Text with structured actions | LLM decomposer → planner → hand-written scripts, plus a text memory of successful plans | ObtainDiamond 67.5% (vs 20% for the RL baseline); first agent to collect all 262 Overworld tech-tree items; memory ablation 35% → 67.5% |
| JARVIS-1 (2023) | Keyboard + mouse at 20 fps | Multimodal planner + self-check replanning + multimodal memory + goal-conditioned controllers | 200+ tasks; diamond pickaxe up to 12.5% — far below the privileged-API agents |
| Cradle (2024) | Screenshots in, keyboard + mouse out | Six modules: information gathering, self-reflection, task inference, skill curation, action planning, memory | Completes 40-minute RDR2 story missions — but the game must be paused while GPT-4o thinks; up to ~1 min between actions; night combat 20% |
| SIMA (2024) | Pixels + language in, keyboard/mouse out | One behaviour-cloned policy trained on human gameplay; latency handled by time-offset action labels, not by pausing | 67% relative improvement over environment-specific agents across commercial games |
| SIMA 2 (2025) | Same, plus text output parsed into actions | Gemini Flash-Lite fine-tuned on gameplay; Gemini Pro as a slow instructor on top | Roughly double SIMA 1's success rate; Flash-Lite chosen explicitly because of latency |
| Lumine (2025) | 720p pixels at 5 Hz in, keyboard/mouse at 30 Hz out | One Qwen2-VL-7B VLA, "hybrid thinking", 20-step short-term memory; trained on 1,731 h of human gameplay | Five-hour Genshin main storyline at roughly human efficiency; needed a 25.3× end-to-end latency cut to get there |
| agent-wow (2026, WoW) | WoW network protocol, no client, no pixels | Coding agent writes its own gRPC modules against a private AzerothCore server | Orc starting zone in ~40 min, 0 deaths; wrote a Detour pathfinder helper; no vision involved |
| WowClassicGrindBot | Addon paints state as coloured pixels; external program reads them and sends keys | GOAP planner over goals with costs; Recast/Detour navmesh; keybindings read from the game | Active, multi-era; Windows only; navmesh queries 1–8 ms; gets stuck on indoor NPCs |
And the honest negative results, which matter more for us:
Put the numbers on one timeline and the architecture question answers itself. The game gives you a global cooldown boundary every 1.5 seconds. Interrupts and dodges want 200–400 ms. Our read costs about 1.1 s, and it starts only after a capture that costs half a second.
Two conclusions fall out. Rotation can be slow, reflexes cannot. One decision per GCD is 0.67 Hz, which a 1.1-second reader can just about serve if the pipeline is deep enough — but interrupts and dodges need a layer that never asks a model. Action must not wait for perception. OpenAI Five acts 7.5 times a second with 167–267 ms reactions by choosing a frame offset inside a four-frame skip; AlphaStar runs at ~110 ms processing with a learned "observe next when" policy. Neither waits for a language model to finish a sentence.
Every action game moves a character by holding a key down for a duration. Our harness could only press and release. That sounds like a small gap and is actually the load-bearing one: without a hold, there is no walking, no turning, no swimming, no steering. So I researched how the working bots do it, built the primitive, and measured what it does.
I read the input layer of WowClassicGrindBot, the most serious open-source WoW bot, and it settles the question: a hold is two events, never a stream. One key-down message, a wait, one key-up message. Nothing repeats in between. The game treats the key as held for the entire interval.
Three details from that codebase are worth stealing outright:
angle × 1000 / 180 milliseconds turns exactly that far — and the bot releases the turn key slightly early to allow for ~30 ms of input latency. Forward bursts are 200–1000 ms; a jump is held 800 ms to swim upward.And the failure modes it documents are the ones that will bite us: a tap where a hold was needed silently does the wrong thing (its drowning code notes that a tap "barely lifts them", which reads from outside as a stuck spacebar); posting messages to a window does not change the key state a polling game reads; and chat is a separate mode where held movement keys must be released first.
WoW is unusually kind here, and this is the finding that shrinks the whole problem. The client has built-in toggles and clicks that replace most holds:
| Control | Toggle or hold | What it replaces |
|---|---|---|
| Autorun | Toggle — one tap on, one tap off; cancelled by forward/back | Holding W for any straight run. It survives turning. |
| Click-to-Move (a client option) | One right-click on the ground | Point-to-point movement. Straight line, no pathfinding, roughly screen-range. |
| Interact with target | One tap | Walking to an NPC, corpse or node and acting on it — with click-to-move on, it turns and closes the distance itself. |
| Mouselook toggle (via a macro) | Toggle | Held right-button steering: enter mouselook, then steer with relative mouse deltas. |
| Auto-attack | Toggle | Every repeat attack. |
| Keyboard turning | Genuine hold — angle = time × 180°/s | Nothing. This is the one that truly needs a timed hold. |
Channelled spells need you to stand still, not to hold anything. No Classic-era ability requires a held key. Addons cannot move the character at all — every movement function has been protected since 2.0.1 — so all motion must come from the input layer's own events or from click-to-move clicks.
So the hold problem reduces to steering. Autorun covers the straight lines, click-to-move and interact cover the destinations, and the only thing left is pointing in a direction — which is either a timed hold of 100 ms per ~18°, or a mouselook toggle plus mouse deltas, which our harness already supports because drag takes a duration.
I wrote the primitive rather than assuming it: a small compiled tool that posts one key-down at the HID event tap, sleeps, then posts one key-up, with a marker so an observer can identify its own events.
holdkey w 600 # one down, 600 ms, one up -> 626 ms wall
holdkey j 15 --repeat=10 --gap=20 # tap loop, for actions not movement
| Question | Measured result |
|---|---|
| Do synthetic key events reach an application? | Yes. A 150 ms hold of a letter put exactly one character into a text field. |
| Does a long hold auto-repeat? | No. A 1000 ms hold produced exactly one keystroke. macOS generates repeat from hardware, not from synthetic events. |
| Does key-state polling see the hold? | No. Polling CGEventSource.keyState at 200 Hz in both hidSystemState and combinedSessionState observed zero transitions during an 800 ms hold. |
| What does one invocation cost? | 33 ms median to spawn the process and post. A reflex loop cannot pay that per keypress — the executor has to be resident. |
CGEventSourceStateID says the state tables accumulate keyboard state "placed in effect by posting events with associated sources", which implies a polled read should see a posted key as down. Our probe saw nothing. Either that guarantee doesn't hold for a synthetic source in this OS version, or the probe is measuring something narrower than the sentence promises. I am reporting the measurement, not the doc, and the practical consequence is the same either way: assume a hold is visible only to consumers that react to the event stream, and verify per target.Our existing driver presses keys by posting to a specific process. That is the wrong route for a game, and cua's own engineering write-up says why: canvas applications — Unity, Blender, games — filter per-process routes entirely and only accept events posted at the HID tap, usually requiring a brief frontmost activation and a leading mouse-move. So the hold primitive has to post at the HID tap, with the game frontmost. This also explains an observation from the first session: the accessibility tree of the game returned nothing useful while pixel capture worked fine.
Two consequences for the build. The executor becomes a resident process that owns a held-key table and a release-all path, not a series of one-shot calls. And the "hold" primitive must be tested against the real client early, because the tier is decided by the client's input model, not by our preference.
These are options, not commitments, ordered by how much of the published evidence each one inherits. Option 1 is the smallest change from what already runs. Option 4 is the only one with a demonstrated path to human-level play, and it is also the only one that needs data we do not have.
Keep the expensive model, but take it off the critical path. A deterministic executor on the second machine owns the keyboard and reads cheap pixel probes at high frequency; the vision model decides what the executor should attempt, once every second or two, and emits a short script rather than a single keypress.
Every serious WoW automation project reads a structured channel, not the UI. The classic trick is an addon that paints game state into a corner of the screen as coloured cells, which an external program decodes without a model at all — microseconds instead of seconds. The catch is that this is exactly the surface Blizzard has been closing. Since the Midnight addon changes, combat values are secret: an addon can display them but cannot compute on them, and a decoder that turned display back into numbers is the thing the policy is aimed at.
Turn the problem around: instead of inferring state from pixels, run a private server that gives state, and let the agent speak the game's own protocol. This is what agent-wow did, and it is why that run finished the orc starting zone in forty minutes with no deaths and no vision at all. It cannot be pointed at a live account, but it can produce something that transfers: validated navigation, validated executor logic, and a scoreboard that cannot lie.
The only architecture with published evidence of a VLM playing a commercial 3D game at human efficiency is Lumine: a 7B model, 720p frames at 5 Hz, keyboard and mouse out at 30 Hz in 33 ms chunks, fine-tuned on 1,731 hours of human gameplay, with a 25.3× latency engineering effort behind it. The uncomfortable implication is that prompting is not the missing ingredient — training data is.
| 1 — three tiers | 2 — addon channel | 3 — protocol oracle | 4 — fine-tuned VLA | |
|---|---|---|---|---|
| Time to something real | Days | Days (non-combat only) | 1–2 weeks | Months |
| Handles 200 ms events | Yes, via probes | Yes, via probes | Not applicable | Best case, ~40 ms key latency |
| Needs model calls in combat | 1 every 1–2 s | No | No | No, one forward pass |
| Blocked by secret values | No — reads pixels | Partly, in combat | No — server has state | No — reads pixels |
| Account risk | On a live account, yes | On a live account, yes | None | On a live account, yes |
| Evidence behind it | Strong (every scaling system) | Strong (GrindBot lineage) | Strong (agent-wow) | Strong but data-hungry (Lumine) |
Separate from play, and interesting because it splits cleanly along the line this post keeps drawing: the analysis is legal, offline, and already possible; the execution is the same automation problem as playing.
Market data is not gated. TradeSkillMaster publishes its price database as static CSV files with no API key, no sign-in and no rate limit (its older web API is a separate thing, keyed and tiered; the CSVs are the free path). I pulled two of them to check:
| File | Result | Columns that matter |
|---|---|---|
retail/us/realm/area-52/items.csv | 1.5 MB, timestamp minutes old | market value, min buyout, recent, historical |
classic/us/region/items.csv | 6,620 rows, dated two days ago | plus saleRate and soldPerDay |
Those last two columns are the whole discipline. They separate a rare item that lists at 19,000 gold and sells once a month from a commodity that sells ninety times a day at a thin margin. Without them, "undercut the lowest listing" is a strategy that quietly loses money; with them, a ranking can be built on gold per day of capital at risk rather than on sticker price.
That is also why the two data sources are not interchangeable. Blizzard's own Game Data API has no sale history at all — you get a snapshot of current listings, refreshed about hourly, and you have to infer sell-through by diffing snapshots over time. TSM's region files give you a computed sale rate directly. If you want to know whether a market actually moves, the third-party number is the one to build on; the official API is for live listings and for anything the addon API won't show.
| Method | Mechanic | What the evidence supports |
|---|---|---|
| Flipping | Buy below the recent market value, relist above it | Realm-specific only: since trade goods became region-wide, cross-realm commodity flipping no longer exists. Profits are personally reported, never independently verified. |
| Sniping | Continuously scan for listings far below value and buy them | The one place automation was specifically closed: since 8.3 each scan and each purchase needs its own hardware event, and TSM rebuilt its sniper around that rule. Scans are throttled. |
| Crafting arbitrage | Craft when the item's price exceeds material cost | Arithmetic, not a trick: sale price minus materials minus the 5% cut minus deposits lost on expired listings. Undercut wars steadily erode it. |
| Shuffles | Convert cheap materials into more valuable outputs (prospect, mill, disenchant) | Dependent on drop rates and fragile across patches; these get nerfed and stay nerfed. |
| Vendor flips | Buy from a vendor, sell on the AH | Small, steady, boring margins — the closest thing to a risk-free rate. |
| Transmog | Farm appearances, list them per realm | High sticker prices and very low sale rates. This is the category that punishes anyone who ranks by price instead of liquidity. |
Every strategy pays the same tolls, and they are large enough to turn a good idea into a loss: a 5% cut on every sale, plus a deposit on non-commodity listings that is gone if the auction expires unsold. A flip that clears a 3% margin after undercutting is a 2% loss after the cut. Any ranking that does not subtract both is not a ranking, it is a wish.
It is worth being exact about where legal ends, because "an addon can't do it" is too vague and "a bot does it" is too coarse. The auction-house functions that actually buy and bid are protected: they require a hardware event, so one real click or keypress per action. Searches are throttled. TSM did not work around that in 8.3 — it rebuilt its sniper to fit it, accepting that a human has to click each buy. That is the true shape of the boundary: on the legal side, the human is the rate limit.
So the honest framing for this subproject is not "agent versus player". It is: everything up to the click can be computed by the agent, and the click stays with James. A good tool makes his clicks count. A bot removes them, and that is the line.
Free history sources worth using: TSM's CSVs for computed prices and sale rates; Undermine Exchange (the successor to The Undermine Journal, which shut down) for charts and an API; Saddlebag Exchange for cross-realm comparisons. Blizzard's Game Data API for live listings if we ever need them, with the caveat that its Classic-era auction endpoints have a history of breaking.
One more layer of reality: the tooling this agent actually drives. Four things I measured or read out of its own schemas today, because each one shapes the build.
| Capability | Reality | Consequence |
|---|---|---|
| Screen capture | Full desktop at 4K: 0.46 s. Window enumeration: 0.067 s. Per-window capture via ScreenCaptureKit exists. | Full-frame capture is the slowest cheap thing in the loop. Window-scoped capture is the next thing to measure. |
| Key input | press_key and hotkey are press-and-release, and they post to the process, not the HID tap. No key-hold primitive; only drag takes a duration. | Solved separately: a resident hold primitive at the HID tap, plus click-to-move and autorun so most motion needs no hold at all. See the key-hold section. |
| Delivery | macOS input posts through SLPSPostEventRecordTo, pid-scoped, no cursor warp; an escalation path to foreground exists for surfaces that ignore background input. Raw-input canvases and games are the documented case. | Expect to need the foreground rung on the game window, and test it on a login screen before trusting it. |
| Accessibility tree | Empty and slow on the game (a 20 s walk timed out). | Pixels are the only channel. Element-index clicking does not apply to a game. |
None of this is a blocker. It is the difference between a plan that assumes a magic input API and one that names the shim it needs to write.