A Local LLM Harness for Building and Testing a Browser Game on a Radeon 780M
My Minus Zero experiment asked whether a small local model could write correct code when it had a written specification and a real test suite. It could. Eight functions shipped, 63 tests passed, and a repeated signed-zero failure gave me a clear reason to escalate from an 8B model to a slower 27B one.
That left a larger question. A six-line numerical function has a narrow contract. A browser game has layout, input, timing, animation, state transitions, mobile constraints, and defects that only appear after the page is running.
I wanted to see whether local models could build and repair the whole thing with a cloud model outside the loop.
The test subject was Neon Bastion, a mobile-first tower-defense game built from index.html, styles.css, and script.js. The final artifact passed 38 browser and static checks at 390×844 and 1280×720. The interesting part was the system that learned to recognize bad generated code.
Splitting the job
The machine was the same Radeon 780M mini PC used in my other local-model experiments. The pipeline gave each stage to the model that fit it:
gpt-oss-20b-16kplanned the architecture, reviewed the result, and proposed bounded repairs.AntLing/Ling-3.0-tiny:q8_0wrote one complete source file per call.- Headless Microsoft Edge executed the game and supplied independent evidence.
The split came from earlier agent runs. A larger local model could reason for minutes without editing anything, repeat the same uncertainty, or use its full response budget before returning the structured answer the harness needed. The smaller model emitted a substantial first draft much more reliably, while its diagnosis of the draft was weaker.
The planner converted an acceptance specification into grammar-constrained JSON. Ling wrote the HTML, CSS, and JavaScript separately. Static checks looked for missing files, remote dependencies, and invalid JavaScript. Edge loaded the game as a phone and as a desktop. The harness captured screenshots, browser exceptions, DOM geometry, HUD values, and a diagnostic snapshot exposed by the game.
Failures went back only to files that could plausibly fix them. The patch model returned exact search-and-replace edits. The browser reloaded after each repair.
Making “looks right” executable
The original tower-defense prompt named four tower types, four enemy types, waves, gold, lives, a path, and a mobile layout. That description still left too much room for a convincing but incomplete implementation.
The eventual contract checked several concrete behaviors:
- A path with at least eight points and useful horizontal and vertical coverage.
- Readable labels for Gold, Lives, Wave, and Score.
- Four touch-sized tower controls with different costs and effects.
- Four enemies with different health and speed, including fast and tank roles.
- Gradual spawning and increasingly difficult waves.
- Rejection of towers placed on the path or bought without enough gold.
- A working economy, damage, kills, score, lives, game over, and restart.
- No overflow at either target viewport.
The game also exposed a read-only window.__gameDebug.snapshot() interface. It reported normalized path coordinates, tower and enemy definitions, current state, and previews of later-wave multipliers. That made the game’s behavior inspectable.
A screenshot can show four differently colored tower buttons. It cannot prove that the towers have different mechanics. A visible enemy does not prove that it moves. A decreasing gold label does not prove that an illegal placement preserves the player’s money.
The validator exercised those boundaries directly. It tried an illegal path placement and a legal ground placement. It checked that enemies changed position, that a tower damaged an enemy, that an unaffordable purchase was rejected, and that later waves became harder.
The first game was convincing and wrong
The first generated version looked enough like a game to create false confidence. Browser evidence found the defects.
On mobile, the canvas occupied too little of the screen and sat between large blank bands. The HUD used icons where the contract required readable labels. On desktop, the canvas expanded beyond the viewport and pushed the tower picker below the fold. Several definition arrays were absent from the diagnostic interface, so visual differences could not be connected to mechanics. Enemy rendering also raised a non-finite-value exception inside createRadialGradient.
This was the first important result. Model self-review and a casual visual glance were weaker than a browser with measurements.
The harness surfaced real defects in the game. Running it also surfaced defects in the harness itself.
When the repair loop damaged its own patient
The repair mechanism initially accepted a patch when its search text matched the current source. That proved the model had referred to real code. It did not establish that the replacement was safe.
Several individually applicable JavaScript edits duplicated function bodies. script.js grew from 21,148 to 38,086 characters and stopped parsing. A repair system meant to make narrow changes had produced a larger regression than the defects it was fixing.
The next version added three guardrails:
- Every JavaScript patch is syntax-checked before it can replace the active file.
- Suspicious file growth is rejected, normally above 15% with an allowance for small files.
- A broken repaired script is quarantined under
_harness, and the saved initial implementation is restored.
The invalid 38 KB script is still preserved. Failed artifacts make the harness debuggable and let a run resume without rewriting its history.
The run exposed other orchestration failures. One gpt-oss call used all 4,000 output tokens on reasoning and returned no JSON. The harness now gives malformed or incomplete replies one bounded retry. A Gold-label failure crossed HTML IDs, styling, and JavaScript, even though the first routing table sent it only to CSS. Failure routing now follows categories. A reviewer can return verdict: "pass" while listing a critical finding, so critical and high-severity findings override that verdict.
The improvements came from reducing the trust placed in model output.
Working inside the 780M
The 780M imposed another orchestration problem. Ling and gpt-oss could not reliably remain loaded together at their useful context sizes. Before each call, the harness inspected Ollama and the local llama.cpp router, then unloaded models left idle by the next stage.
Observed generation speed was usable, although every stage took long enough to measure:
| Stage | Model | Time | Speed |
|---|---|---|---|
| Architecture plan | gpt-oss 20B | 131.1 s | 18.6 tok/s |
| Accepted HTML | Ling Q8 | 22.8 s | 37.0 tok/s |
| CSS | Ling Q8 | 65.9 s | 35.6 tok/s |
| Initial JavaScript | Ling Q8 | 249.9 s | 31.4 tok/s |
Medium-reasoning repair calls sometimes took four to seven minutes and could use their full budget before returning a patch. Moving bounded patch work to low reasoning made small repairs complete in tens of seconds. Planning and final review kept the larger reasoning budget.
This was a slow pipeline. Its slowness stayed visible, bounded, and resumable.
The last failure
The final Neon Bastion artifact has 45 preserved model-response files, 13 validation snapshots, two viewport screenshots, and the quarantined invalid script. Its active source is about 31 KB across the three files and passes all 38 checks with no browser errors.
The last failing check was path-placement-rejected.
The generated isOnPath() helper normalized each path segment, then discarded every segment because it compared normalized lengths against 1. Every valid normalized segment was therefore treated as too short to test. Resolution changes, CSS repairs, and broader model patches left that geometrical predicate untouched.
I inspected the failure manually and replaced the threshold with a small epsilon. The next validation passed 38 of 38.
That human intervention belongs in the result. The harness reduced a full interactive game to one specific, reproducible failure whose cause could be understood and corrected. That is a useful failure mode: the system gave me a small problem instead of a broken page hidden behind a success message.
What changed since Minus Zero
Minus Zero tested a model against an existing contract. This experiment tested the machinery around several models.
The earlier loop asked whether a small model could implement a function, and whether a larger model helped when the failure signature repeated. The tower-defense harness asked whether independent evidence could guide planning, generation, repair, visual inspection, and recovery across an entire browser application.
Local models can produce a surprisingly complete first draft. A larger local model can diagnose and patch it. The useful unit is the loop around the model call: a precise contract, executable evidence, bounded edits, preserved artifacts, and a way to resume after a failure.
Minus Zero found a capability wall in signed-zero arithmetic. Neon Bastion made the trust boundaries visible across an application-sized task. Neon Bastion itself was never the point. It existed so the harness had something real to fail against and repair.