<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Zachariah Leonard&apos;s Blog</title>
    <link>https://zachariahleonard.com/blog/</link>
    <description>Technical deep dives into AI automation, LLM agents, and software engineering.</description>
    <language>en-us</language>
    <lastBuildDate>Tue, 14 Jul 2026 12:00:00 GMT</lastBuildDate>
    <atom:link href="https://zachariahleonard.com/blog/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Embodiment problems: the model is only as capable as its senses</title>
      <link>https://zachariahleonard.com/blog/?post=rs-gym-part-4</link>
      <guid isPermaLink="true">https://zachariahleonard.com/blog/?post=rs-gym-part-4</guid>
      <pubDate>Tue, 14 Jul 2026 12:00:00 GMT</pubDate>
      <description>The bot fights goblins bare-handed with a bronze sword in its inventory, and walks into fences it cannot see. Two experiment rounds on equipment and spatial perception - both produced results that complicate the story.</description>
      <content:encoded><![CDATA[<p>Two observations from watching the bot for hours, each of which became an experiment round. Both rounds are now complete, and both produced results that complicate the story.</p>
<h2>Observation 1: it never equips anything</h2>
<p>The bot fights goblins bare-handed with a bronze sword in its inventory. Across twenty-plus runs it essentially never called <code>equipItem()</code>. The cause was sensory: nothing in the world digest reported equipment state, so no prompt ever said &quot;you are holding nothing.&quot; An API the model has never used and never sees evidence about effectively does not exist.</p>
<p>This motivated a three-way experiment to locate <em>where</em> the intervention belongs:</p>
<ul><li><strong>Unused-API nudge (U):</strong> each turn, show one randomly chosen never-used API with a hint (&quot;you have never used bot.equipItem()...&quot;). Prompt-level.</li><li><strong>Equipment visibility (Z):</strong> add a &quot;## Equipment worn: NOTHING - equippable items sitting unused in inventory: bronze sword&quot; line to the digest. Sense-level.</li><li><strong>Auto-equip (P):</strong> a wrapper that silently equips the best weapon before any attack. Actuator-level.</li></ul>
<p>Given the ledger (sensed truth wins, prompt directives lose), my registered prediction was P &gt; Z &gt; U. The result did not follow the prediction:</p>
<table><thead><tr><th>Condition</th><th>XP</th><th>Error turns</th><th>Note</th></tr></thead><tbody><tr><td>CEF + auto-equip (P)</td><td>+1120</td><td>-</td><td>within the baseline variance band</td></tr><tr><td>CEF + unused-API nudge (U)</td><td>+900</td><td>-</td><td>nudge read, not acted on; skills trained unchanged</td></tr><tr><td>CEF + equip visibility (Z)</td><td>+130</td><td>16</td><td>confounded; see below</td></tr><tr><td>CEF + equip visibility (Z, rerun)</td><td>+701</td><td>5</td><td>first <code>equipItem</code> calls ever observed</td></tr></tbody></table>
<p>The original Z run was not a clean read on equipment semantics. Partway through, the model collapsed into writing Python instead of TypeScript (&quot;print is not defined&quot;, <code>if rat:</code> syntax), and the history window sustained the derailment for the rest of the episode. The rerun was clean and produced the most informative null result of the round. The bot called <code>equipItem()</code> eight times (bronze axe, bronze dagger), the first equips observed anywhere in the project, and the score still did not separate from baseline. That splits the hypothesis in two: visibility was the missing ingredient for the behavior, and the behavior was not a missing ingredient for the score. The most likely explanation is that equipment was never the binding constraint. At rat-and-goblin tier, bare-handed combat trains the same skills only slightly slower, so a perfect equip policy has little XP to add. Prompt-level nudges are now 0-for-4 across rounds, which keeps the sense/actuator hierarchy intact even in a null round. If exploration is worth forcing, the next design is epsilon-greedy at the actuator level: occasionally execute an unexplored action rather than suggesting it.</p>
<h2>Observation 2: the 3D-to-1D bottleneck</h2>
<p>A language model has no visual capacity. The whole game world reaches it as text, and my translation was lossier than I realized. Before this round, the digest gave NPCs no coordinates at all, just names and distances. A report like &quot;Goblin, 7 tiles&quot; carries no direction. The model's most common visible failure was walking into fences.</p>
<p>Three flags targeted this:</p>
<ul><li><strong>Compass bearings (J):</strong> annotate every entity with egocentric bearings: &quot;Shop keeper 12t N (3208,3248)&quot;. Cheap, purely sensory.</li><li><strong>ASCII minimap (V):</strong> an 11x11 character grid centered on the player: trees as T, NPCs as N, ground items as $. The legend carries a caveat: walls and water are NOT shown, because the SDK exposes no collision data.</li><li><strong>Learned obstacle map (I):</strong> since the SDK can't sense walls, <em>learn</em> them. A wrapper watches every <code>walkTo</code>: if the bot stops more than a tile short of its destination, that spot is recorded and future prompts list nearby known blockages. The map starts empty and fills in from experience. By the ledger's logic, memory earned from the world should work where seeded memory failed.</li></ul>
<p>Results, accumulated across several rounds:</p>
<table><thead><tr><th>Condition</th><th>XP</th><th>Error turns</th><th>Skills trained</th></tr></thead><tbody><tr><td>CEF + bearings (J)</td><td>+695</td><td>1</td><td>3</td></tr><tr><td>CEF + bearings (J, replicate)</td><td>+1488</td><td>4</td><td>3</td></tr><tr><td>CEF + minimap (V)</td><td>+1281</td><td>6</td><td>3</td></tr><tr><td>CEF + minimap (V, replicate)</td><td>+1860</td><td>11</td><td>2</td></tr><tr><td>CEF + both (JV)</td><td>+100</td><td>1</td><td>1</td></tr><tr><td>CEF + both (JV, replicate)</td><td>+0</td><td>13</td><td>0</td></tr><tr><td>CEF + both (JV, second replicate)</td><td>+465</td><td>8</td><td>2</td></tr><tr><td>CEF + obstacle map (I)</td><td>+1085</td><td>4</td><td>2</td></tr><tr><td>CEF + all three (IJV)</td><td>+2515</td><td>2</td><td>2</td></tr><tr><td>CEF + all three (IJV, replicate)</td><td>+1475</td><td>5</td><td>2</td></tr></tbody></table>
<p>Two results did not fit the existing pattern.</p>
<p>First, J and V were individually tolerable but jointly catastrophic in round one (+100). This was the second observed instance of observation features interfering (world-state deltas + memory blackboard was the first), and I concluded that prompt real estate is a budget. The next round, the same J+V pair inside IJV posted +2515, at the time the highest 20-turn episode recorded. One of those two episodes had to be misleading. With n=1 per cell there was no way to know which, so a replicate ran under the exact original protocol: JV scored +0 with 13 error turns and zero skills trained. A later round added a third JV episode and replicated both single flags. JV proper is now three-for-three collapses (+100, +0, +465). J alone replicated at +1488 and V alone at +1860; each flag individually lands in the normal band across two episodes. &quot;Jointly toxic&quot; therefore stands for the bare pair, on the strongest evidence of any negative result in the ledger.</p>
<p>IJV remains unexplained. Its two episodes (+2515 and +1475) show no collapse, even though the obstacle-map flag never fired in either (below). Nominally they are JV episodes plus an inert flag. Whatever the I flag changes in the prompt or the minimap rendering when its map is empty apparently neutralizes the interference. I do not have a mechanism for this. Five episodes with no range overlap (JV tops out at +465, IJV bottoms out at +1475) make it the most reproducible anomaly in the project. The obvious next experiment is a diff of the two conditions' actual prompts.</p>
<p>Second, the obstacle-map flag never actually engaged: zero stalls were recorded in any of its three episodes. The mechanism only matters when the bot walks into blocked paths, and these episodes happened to settle into open-ground woodcutting loops. So the I rows above carry no obstacle-map signal, and the earned-memory hypothesis remains untested rather than supported or refuted. (Its interrupt variant, which aborts the turn on a stall instead of annotating it, was tested and hurt; that result is in the previous post.)</p>
<h2>The pattern, revised</h2>
<p>The original framing of this post's observations was: every problem here is an embodiment problem, not an intelligence problem. The model isn't failing to reason about equipment or walls; it has never been told they exist. That framing survives, but the results qualify it. The early sensing and actuator flags (world-state deltas, combat kill-wait, ground-item visibility, the precondition guard) produced large, repeatable wins because they fixed constraints that were actually binding. The round-13-15 additions mostly did not separate from baseline, because the constraints they fixed - equipment, bearings - turned out not to be what was limiting XP at this stage of the game. Building the sensory layer is still the job; knowing <em>which</em> sense is the bottleneck is the part I underestimated.</p>
<p>Open problems: the money half of the standing goal is untouched (coins are zero in nearly every episode recorded); episode variance still swallows single-run conclusions, and the replicate queue keeps growing; and the strongest configuration in the project is a fast, shallow coder model that was nearly dismissed early on. Five episodes in, its mean sits around +3420, and its worst run (a mid-episode death) still roughly matched the best qwen3.5:4b episode (details in the previous post).</p>]]></content:encoded>
    </item>
    <item>
      <title>Owning the clock: experimenting on the engine, not just the agent</title>
      <link>https://zachariahleonard.com/blog/?post=rs-gym-part-3</link>
      <guid isPermaLink="true">https://zachariahleonard.com/blog/?post=rs-gym-part-3</guid>
      <pubDate>Mon, 13 Jul 2026 12:00:00 GMT</pubDate>
      <description>Because the whole stack is self-hosted, the game engine itself is a variable. Freezing the world during inference, sweeping the tick rate, and scoring models on wall time instead of turns - several of these outperformed anything done to the prompt.</description>
      <content:encoded><![CDATA[<p>Most agent-scaffolding work treats the environment as fixed and iterates on prompts and tools. Because this whole stack is self-hosted, the game engine itself is a variable. The interventions in this post are temporal: they change when the model thinks, how fast the world moves, and what a fair score is. Several of them outperformed anything done to the prompt.</p>
<h2>Freezing the world during inference</h2>
<p>The first post introduced the mechanism: a tick-control endpoint (<code>/api/tick-control?action=pause|resume|rate|status</code>) pauses the engine while the model thinks and resumes it while the generated code executes, so the agent plays in strict lockstep. What that post skipped is the methodological payoff. Pausing separates two questions that are usually conflated:</p>
<ul><li>1. Can the model make good decisions given accurate state?</li><li>2. Can it keep up with a real-time environment?</li></ul>
<p>Freezing the world isolates question 1. Every experiment in this series answers that question, not question 2. Latency stops being a confound and becomes a separately measurable quantity (wall minutes per episode).</p>
<h2>Tick rate as an experimental knob</h2>
<p>The engine's tick rate (default 600ms per game tick) controls how fast the world runs during the execution phase. The run protocol takes a per-condition <code>tickMs</code> argument, so tick rate became just another flag to sweep:</p>
<table><thead><tr><th>tickMs</th><th>XP gained</th><th>Error turns</th><th>Wall minutes</th></tr></thead><tbody><tr><td>100</td><td>+205</td><td>11</td><td>34.7</td></tr><tr><td>150</td><td>+1540</td><td>10</td><td>11.1</td></tr><tr><td>200</td><td>+1173</td><td>1</td><td>8.6</td></tr><tr><td>300</td><td>+1902 / +769 (two runs)</td><td>5 / 14</td><td>24.2 / 42.2</td></tr><tr><td>600 (default)</td><td>~+1400 mean</td><td>varies</td><td>~30-39</td></tr><tr><td>1200</td><td>+1063</td><td>7</td><td>61.9</td></tr></tbody></table>
<figure><img src="https://zachariahleonard.com/blog/media/tick-throughput.png" alt="Throughput vs tick rate: an inverted U peaking at 150-200ms" /><figcaption>Throughput vs tick rate: an inverted U peaking at 150-200ms</figcaption></figure>
<p>Reading the chart: each dot is one episode's throughput (XP per wall-minute) at that tick rate; the dashed line traces the group means. The shape is the finding: an inverted U where both extremes lose, and the 150-200ms peak delivers roughly two and a half times the throughput of the default 600ms setting.</p>
<p>Measured as throughput, the curve is an inverted U peaking around 150-200ms: roughly 136-139 XP per wall-minute versus 55 at the default 600ms. Interpretation:</p>
<ul><li>Too slow (600-1200ms): wall time is dominated by watching actions complete. Decision quality is unchanged; throughput suffers.</li><li>Too fast (100ms): actions and their consequences resolve faster than the feedback loop can represent them, and error turns spike.</li><li>300ms produced the highest run in the sweep (+1902) but failed to replicate (+769 on rerun), a caution about drawing conclusions from single episodes.</li></ul>
<p>I settled on 200ms as the default based on the error profile rather than the raw throughput ranking: 150ms edges it slightly on XP per minute (139 vs 136), but at the cost of ten error turns versus one. A plausible explanation is that 200ms sits at the slow edge of the throughput plateau, where the world still advances quickly but every action's result has fully resolved before the next decision is made. The model then never reasons from a half-finished state.</p>
<h2>Do small models need structured tool calls?</h2>
<p>The agent's native interface is &quot;write a TypeScript snippet against the bot SDK.&quot; The prevailing assumption in agent design is that structured tool calling is the right way to have models act. An A/B test: same base stack, same 20 turns, one condition using the OpenAI-style <code>tools</code> parameter with a fallback parser, one condition just asking for a fenced code block.</p>
<p><strong>qwen2.5-coder:7b</strong> with tool calling: +470 XP, 11 error turns. <strong>qwen2.5-coder:7b</strong> with plain code blocks: +710 XP, <strong>0</strong> error turns.</p>
<p><strong>phi4-mini</strong> was more extreme: with tool calling enabled it refused to play at all. With code blocks it scored +585 XP with 1 error turn.</p>
<p>Based on the error-rate collapse (11 to 0 for coder-7b, refusal to near-flawless for phi4-mini), I dropped the <code>tools</code> parameter for the small-model conditions: at this scale, tool-calling APIs behave like a compatibility layer, not a capability. A plausible mechanism is training distribution: these models saw far more code than tool-call JSON. Asking for code matches what they know. The tool-call envelope is a format they have seen far less often, and the mismatch surfaces as wasted turns.</p>
<p>This does not remove the need for validation; it relocates it. The strictness a tool-call schema would have provided moved into the execution environment instead: the precondition guards from the flag-ledger post veto bad actions after parsing, rather than constraining the model's output format before it.</p>
<p>One incident from the same round is worth recording: a synchronous busy-wait in generated code froze the Bun event loop, taking down the websocket and with it the bot's connection. The fix (an execution timeout plus async-only wait helpers) doesn't show in any results table, but it is the difference between a bad turn and a dead agent. Model-written code needs real sandboxing.</p>
<h2>Scoring by wall time instead of turns</h2>
<p>The tool-call results surfaced a distinction I had not been measuring: per-decision quality vs per-minute throughput. qwen3.5:4b makes the best individual decisions but thinks slowly; the codeblock coder models make shallower decisions much faster. Twenty-turn episodes structurally favor the slow, careful models; the fast models' advantage is capped at exactly 20 decisions.</p>
<p>The fix required no new code. Setting the turn limit above what the wall-clock deadline permits makes the deadline the binding constraint. Every condition then gets exactly 15 wall-minutes, and the metric becomes XP per fixed time. Results, all at 200ms tick with the tuned decoding knobs (maxTokens 1024, temperature 0, history window 6, precondition guard on):</p>
<table><thead><tr><th>Condition</th><th>XP in 15 min</th><th>Decision turns</th><th>Error turns</th></tr></thead><tbody><tr><td>qwen2.5-coder:7b, code blocks</td><td><strong>+5035</strong></td><td>45</td><td>0</td></tr><tr><td>qwen2.5-coder:7b, code blocks (run 2, died mid-run)</td><td>+1881</td><td>95</td><td>0</td></tr><tr><td>qwen2.5-coder:7b, code blocks (run 3)</td><td>+2820</td><td>39</td><td>0</td></tr><tr><td>qwen2.5-coder:7b, code blocks (run 4)</td><td>+3680</td><td>42</td><td>0</td></tr><tr><td>qwen2.5-coder:7b, code blocks (run 5)</td><td>+3675</td><td>43</td><td>0</td></tr><tr><td>qwen3.5:4b (run 1)</td><td>+1903</td><td>21</td><td>1</td></tr><tr><td>qwen3.5:4b (run 2)</td><td>+1107</td><td>14</td><td>0</td></tr><tr><td>qwen3.5:4b (run 3, reused account)</td><td>+1545</td><td>24</td><td>0</td></tr><tr><td>qwen3.5:4b (run 4)</td><td>+586</td><td>32</td><td>4</td></tr><tr><td>phi4-mini, code blocks</td><td>+660</td><td>160</td><td>0</td></tr></tbody></table>
<figure><img src="https://zachariahleonard.com/blog/media/walltime-bakeoff.png" alt="Fixed wall-time bake-off" /><figcaption>Fixed wall-time bake-off</figcaption></figure>
<p>Reading the chart: every bar is one 15-minute episode on the same stack (precondition guard, 200ms ticks, tuned decoding knobs); only the model and prompt format vary. Green bars are qwen2.5-coder:7b with code-block prompting, gray bars everything else. Compare within the greens for coder-7b's own spread, and greens against the first four grays for the model comparison: coder-7b's worst episode (the one where it died mid-run) still roughly matches qwen3.5:4b's best.</p>
<p>Two findings.</p>
<p>First, coder-7b's +5035 is the highest episode score recorded in the project. Across five episodes the configuration's mean is about +3420, roughly 2.9x the tuned qwen3.5:4b configuration's clean-episode mean of about +1200. Given equal time, fast shallow decisions win in this world, where most correct actions are simple. Run 2 scored +1881 and is not a clean read: the bot died mid-episode, lost its axe, and spent much of the remaining time attempting to chop with empty hands (73 consecutive precondition-guard vetoes in the transcript). Even that episode roughly matched the 4b configuration's best run. Runs 3-5 came back clean at +2820, +3680, and +3675. That band puts +5035 at the high end of the distribution and the death at the low end, and it establishes coder-7b with code blocks as the base configuration. (qwen3.5:4b accounting: run 3's +1545 is excluded from the mean because a bot-name collision resumed an old account rather than a fresh one. Its fresh-account replacement, run 4, scored +586. The 4b configuration's own episodes therefore span 586-1903; variance spares no configuration.) The death in run 2 also exposed a gap the 20-turn protocol could not: episodes are now long enough for rare catastrophic events to matter, and the agent has no recovery behavior. The death has not recurred in three later runs, so its base rate appears low but nonzero.</p>
<p>Second, phi4-mini collapsed under the format change: 160 decisions in 15 minutes but only +660 XP. Its earlier throughput number came from a short 20-turn episode. Given more time, it made very fast decisions with almost no productive action per turn. Fixed-wall-time scoring exposed both facts; the 20-turn protocol had hidden them.</p>
<h2>Event-triggered thinking: a negative result</h2>
<p>The polling loop (think, execute a fixed window, think again) spends inference whether or not anything decision-relevant happened. The alternative is interrupts: wake the model only when an event invalidates its plan. The flag ledger already hinted this could work. Combat kill-wait, one of the strongest flags, is a negative trigger: it suppresses thinking during combat until resolution. That suggests plan-invalidating events are valuable wake conditions and mere activity is not.</p>
<p>I implemented two triggers: a one-shot low-HP crossing (with hysteresis, so it fires once per dip rather than repeatedly; the low-HP safety-warning flag already showed persistent advice backfires) and a walk-stall detector (if the bot stops short of its destination, execution aborts with <code>INTERRUPT: walkTo stopped at (x,z), N tiles short - path may be blocked</code>). The interrupt ends the turn early, keeps the world paused, and hands the model the named reason as ground truth.</p>
<p>Result, against a same-protocol control:</p>
<table><thead><tr><th>Condition</th><th>XP in 15 min</th><th>Decision turns</th></tr></thead><tbody><tr><td>qwen3.5:4b + interrupts</td><td>+307</td><td>33</td></tr><tr><td>qwen3.5:4b control</td><td>+1107</td><td>14</td></tr></tbody></table>
<p>The mechanism worked as designed: interrupts fired correctly and decision density more than doubled. The outcome still got worse. The transcript shows why: after two early walk-stall interrupts, the bot abandoned travel entirely and spent the rest of the episode killing rats next to its starting point. The interrupt message was true, but it functioned like the failed safety-warning flag: accurate information that redirected behavior for the worse. The model treated one blocked path as a reason to stop traveling at all.</p>
<p>My pre-registered prediction (more decision turns at equal or better XP) was half right: decision turns rose, XP fell. If this gets another iteration, the refinements are mechanical: require a repeated stall before interrupting, and phrase the reason as an actionable option (&quot;try openDoor() or route around&quot;) rather than a diagnosis. But one episode per condition is thin evidence, and the honest summary is that the interrupt <em>mechanism</em> is validated and the first trigger tuning is not.</p>
<h2>Remaining surface</h2>
<p>Several temporal parameters remain untested: dynamic tick rate (fast during travel and gathering, slow near combat; the endpoint supports live changes, nothing schedules them yet), variable execution windows per action type, and partial pausing (freezing only the agent's local chunk, which matters if multiple bots ever share the server).</p>
<p>Next post covers the embodiment experiments: equipment, spatial perception, and what a model that cannot see does with a map.</p>]]></content:encoded>
    </item>
    <item>
      <title>Measuring what helps: a factorial harness and the flag ledger</title>
      <link>https://zachariahleonard.com/blog/?post=rs-gym-part-2</link>
      <guid isPermaLink="true">https://zachariahleonard.com/blog/?post=rs-gym-part-2</guid>
      <pubDate>Sun, 12 Jul 2026 12:00:00 GMT</pubDate>
      <description>Episode variance is enormous: a single run that appears to prove a feature works is noise. A factorial experiment harness and a flag ledger reveal which features actually help a tiny model play - and the pattern in what won and what lost.</description>
      <content:encoded><![CDATA[<p>Once the bot was watchable, I had a dozen ideas for making it smarter: a shared memory blackboard, world-state diffs instead of full dumps, a gazetteer of known locations, auto-waiting during combat. Which of these actually help?</p>
<p>Watching cannot answer that. Episode variance is enormous: the same configuration scored +815, +1730, and +1764 XP across three replicates, and the strongest configuration in the project later spanned +1881 to +5035 across five. A single run that appears to prove a feature works is noise.</p>
<figure><img src="https://zachariahleonard.com/blog/media/episode-variance.png" alt="Same configuration, different episodes: replicate spread across eight configurations. Every group spans a wide band except CEF+JV, whose three episodes all collapsed with a consistency only a genuine effect produces." /><figcaption>Same configuration, different episodes: replicate spread across eight configurations. Every group spans a wide band except CEF+JV, whose three episodes all collapsed with a consistency only a genuine effect produces.</figcaption></figure>
<p>Reading the chart: each dot is one complete episode, colored by model. Every group ran on qwen3.5:4b (green) except the rightmost, which ran on qwen2.5-coder:7b (blue). Each gray bar is a group mean. The two panels are different protocols (the left panel ran fixed 20-turn episodes, the right panel fixed 15-minute wall-time episodes), so scores compare within a panel but not across panels. Two takeaways: nearly every group spans a 2x-or-worse band between its best and worst episode, which is why single runs prove nothing; and CEF+JV is the one exception, three episodes all collapsed near zero. When replicates agree that tightly, the effect is real. (The flag letters and the right panel's configurations are covered in this post and the following two.)</p>
<h2>The harness</h2>
<p>Every feature is a boolean flag in an <code>experiment.json</code> config: <code>blackboard</code>, <code>deltas</code>, <code>killwait</code>, <code>ground</code>, <code>places</code>, and eventually about twenty more. Each flag gates one change to the prompt, the executor, a wrapper around the bot API, or the game engine itself. The run protocol takes a per-condition tick rate, and the world-pause lockstep is part of the harness, so server-side parameters are swept the same way as prompt features. A run gets a label encoding its active flags (condition &quot;CEF&quot; = deltas + killwait + ground-items).</p>
<p>A shell protocol (<code>run-experiment.sh</code>) makes each condition a clean episode:</p>
<ul><li>1. create a fresh bot account and speedrun the tutorial (using the provided &quot;Skip Tutorial&quot; functionality)</li><li>2. record the XP baseline (always 980 after tutorial)</li><li>3. run exactly 20 decision turns</li><li>4. append <code>{label, xpGained, coins, errorTurns, wallMinutes, ...}</code> to <code>results.jsonl</code></li></ul>
<p>Twenty turns is short, but it is the compromise that makes volume possible: a condition takes 10-60 minutes wall clock, so an overnight batch covers 3-5 conditions. Batches chain themselves: each round's script polls the previous round's output file for a completion marker, then starts.</p>
<p>The metrics:</p>
<ul><li><strong>xpGained:</strong> the primary score, since the standing goal is &quot;train every skill&quot;</li><li><strong>coins:</strong> the money half of the goal (almost always zero in practice; still an open problem)</li><li><strong>errorTurns:</strong> turns where the model's code threw; a proxy for how badly the setup is confusing it</li><li><strong>wallMinutes:</strong> because a feature that doubles quality but triples latency is a loss</li></ul>
<p>Early rounds tested flags in combination (A, B, C, AB, AC, BC, ABC) because features interact. The world-state deltas flag alone was worth +755 vs a +232 baseline; stacking it with the memory blackboard destroyed it (+31). After the first rounds established a solid base stack (CEF), later rounds became champion vs challenger: test each new flag as CEF + one thing.</p>
<p>The single most important discipline turned out to be pre-registering a prediction before each round and writing down the result either way. Roughly half of my confident predictions were wrong, and the wrong ones were where all the learning was.</p>
<h2>What won</h2>
<p>Numbers are XP gained over 20 turns; the untreated baseline averaged roughly +900-1400 with large variance.</p>
<ul><li><strong>World-state deltas (C):</strong> show the model <em>changes</em> in world state instead of a full dump every turn. +755 alone vs +232 baseline; the foundation of every later stack.</li><li><strong>Combat kill-wait (E):</strong> after <code>attackNpc</code>, automatically block until combat resolves instead of letting the model act on mid-fight state. CE hit +1715.</li><li><strong>Ground-item visibility (F):</strong> include what's lying on the ground in the digest. CEF hit +1764 and became the standard base.</li><li><strong>Precondition guard (N):</strong> intercept doomed actions before they execute, so <code>chopTree()</code> with no axe returns &quot;you need an axe&quot; instead of a game-engine error. +1860 with 2 error turns, the highest-scoring single flag tested.</li><li><strong>Faster engine ticks (T):</strong> peak throughput at a 150-200ms tick rate, about 136 XP/min vs 55 at the default 600ms (details in the next post).</li></ul>
<h2>What lost</h2>
<ul><li><strong>Memory blackboard (A):</strong> a persistent shared memory the model writes notes to. +10. It filled the context with incorrect self-generated notes, which the model then treated as fact.</li><li><strong>Bigger model (B):</strong> qwen3.5:9b scored <em>worse</em> than 4b (+355 vs ~+1400 on the same stack) while taking twice the wall time. The extra parameters did not improve grounding and doubled the wall time.</li><li><strong>Objective hints (D):</strong> telling the model <em>what to pursue</em> (&quot;you should train fishing next&quot;). CD +140 vs C +755.</li><li><strong>Location gazetteer (G):</strong> a seeded list of known world locations. +285. It walked to distant landmarks instead of using what was in front of it.</li><li><strong>Untrained-skill reminders (O):</strong> listing which skills are at zero. +140 with 13 error turns, the lowest score of any flag tested.</li><li><strong>Low-HP safety warnings (S):</strong> &quot;your HP is low, be careful&quot;. +320. One run ended at 5 HP; the warnings appeared to draw the model toward combat rather than away from it.</li></ul>
<figure><img src="https://zachariahleonard.com/blog/media/flag-ledger.png" alt="Flag ledger: sensing and actuator flags vs prompt-advice flags" /><figcaption>Flag ledger: sensing and actuator flags vs prompt-advice flags</figcaption></figure>
<p>Reading the chart: each bar is a 20-turn episode's XP with that flag in play (the sensing flags stack cumulatively: C, then CE, then CEF). Green marks flags that sense the world or enforce action semantics; gray marks flags that inject advice into the prompt; the dark bar is the untreated baseline. The worst green flag more than tripled the baseline; the best gray flag reached +320. That clean split is the finding.</p>
<h2>The pattern</h2>
<p>Every winner is <strong>ground truth the system sensed</strong> or <strong>action semantics the system enforced</strong>. Every loser is <strong>knowledge or direction a human seeded into the prompt</strong>.</p>
<p>Flags that describe what is (world-state deltas, ground items, honest action results) improved scores. Flags that tell the model what to think or want (objectives, warnings, gazetteers, its own past notes) made them worse. A 4B model does not reliably arbitrate between a directive and its senses; the directive gets applied at the wrong moment and functions as noise.</p>
<p>The precondition guard is the clearest case: rather than describing axe requirements in the prompt, let the model try, and make the failure message true and immediate. It is mechanical, costs no context, and outscored every prompt-level instruction tested.</p>
<figure><img src="https://zachariahleonard.com/blog/media/ui2-mid1.png" alt="The precondition guard firing on a live run: the bot has died, lost its axe, and every chop attempt returns a true, actionable failure message. The world-frozen badge (top left) shows the lockstep pause during inference." /><figcaption>The precondition guard firing on a live run: the bot has died, lost its axe, and every chop attempt returns a true, actionable failure message. The world-frozen badge (top left) shows the lockstep pause during inference.</figcaption></figure>
<p>The summary: better senses and better actuators, not better advice.</p>
<p>Next post covers the engine-side experiments: freezing the world, sweeping the tick rate, and letting fast models compete on wall time.</p>]]></content:encoded>
    </item>
    <item>
      <title>A RuneScape gym for tiny local models</title>
      <link>https://zachariahleonard.com/blog/?post=rs-gym-part-1</link>
      <guid isPermaLink="true">https://zachariahleonard.com/blog/?post=rs-gym-part-1</guid>
      <pubDate>Sat, 11 Jul 2026 12:00:00 GMT</pubDate>
      <description>Measuring what a 4B parameter model running on my own GPU can do in an open-ended environment rather than on a benchmark: a fully self-hosted 2004-era RuneScape private server, bot accounts, and an initial standing goal to make money and train every skill.</description>
      <content:encoded><![CDATA[<p>I wanted to measure what a 4B parameter model running on my own GPU can do in an open-ended environment rather than on a benchmark. The environment: a fully self-hosted 2004-era RuneScape private server (via <a href="https://github.com/MaxBittker/rs-sdk">rs-sdk</a>), bot accounts, and an initial standing goal: <strong>make money and train every skill</strong>.</p>
<p>Everything runs locally. The game engine serves on localhost, a gateway bridges the bot client over a websocket, and a small Bun server I call the &quot;agent server&quot; sits in the middle. Each decision turn, the agent server snapshots the world state (nearby NPCs, trees, ground items, inventory, skills) and renders it into a text prompt. A local Ollama model (mostly qwen3.5:4b in the early rounds, later a 7B coder model) responds with a snippet of TypeScript against a bot SDK: <code>bot.walkTo(x, z)</code>, <code>bot.chopTree()</code>, <code>bot.attackNpc(/rat/i)</code>, and so on. The snippet runs, the world advances, and the loop repeats.</p>
<p>Everything runs at zero marginal cost, which changes what experiments are feasible: a failed 20-turn episode costs nothing but wall-clock time, so weak hypotheses are worth testing.</p>
<h2>Pausing the world during inference</h2>
<p>A 4B model on consumer hardware takes tens of seconds to produce each decision. The game is real-time, so the world keeps advancing during that delay. By the time the generated code runs, the state the model reasoned about is stale: the rat has moved, the fire has burned out.</p>
<p>Since I own the server, I fixed this at the source: the game world <strong>pauses while the model thinks</strong>. A tick-control endpoint (<code>/api/tick-control?action=pause|resume</code>) freezes the engine's tick loop during inference and resumes it during execution. The agent plays in lockstep, like a turn-based game.</p>
<p>This changes the question under test. Whether a small model can play an MMO in real time has a known answer: no. The open question is whether it can make sensible decisions given accurate state. A later post covers the engine-side experiments this enabled, including treating the tick rate itself as an experimental variable.</p>
<h2>What the model actually sees</h2>
<p>No pixels. The model gets a structured text digest:</p>
<ul><li>its position and stats</li><li>nearby objects and NPCs with distances</li><li>inventory and recent skill XP changes</li><li>the result message from its last action</li></ul>
<p>And it responds with code. That's the whole interface. Everything else in this series (the UI, the experiment framework, the dozens of feature flags) is about tuning what goes into that digest and what happens to the code that comes out.</p>
<h2>Making it watchable</h2>
<p>The first version of this project was logs scrolling in a terminal. Logs confirm the loop runs. They do not explain behavior, such as why the bot walked into a fence for ten minutes. So before any real experiments, I built a single-page UI that shows everything at once:</p>
<figure><img src="https://zachariahleonard.com/blog/media/ui-mid2.png" alt="The agent UI mid-episode: game view, minimap, the model's reasoning and code in the chat log, and the script panel" /><figcaption>The agent UI mid-episode: game view, minimap, the model's reasoning and code in the chat log, and the script panel</figcaption></figure>
<ul><li><strong>The game itself</strong>, embedded as an iframe. The bot client is a real browser game client, so the agent's character is visible in the same view a human player would have.</li><li><strong>A chat log</strong> of the conversation: every prompt turn, every model reply, every execution result.</li><li><strong>A script panel</strong> showing the exact TypeScript the model wrote this turn, with a toggle between the raw model output and the cleaned code that actually executed.</li><li><strong>A pause indicator.</strong> Under lockstep the world freezes every time the model thinks. Without a badge saying &quot;paused: thinking&quot;, a frozen game is indistinguishable from a crashed one.</li><li><strong>A send box</strong>, for injecting a message into the agent's context mid-run.</li></ul>
<p>None of this is elaborate: one HTML file, no framework, a websocket. Its value was methodological: failure modes that aren't observable don't generate hypotheses. Most of the feature flags tested in later posts trace back to something first noticed in this UI:</p>
<ul><li>Watching it repeatedly try <code>chopTree()</code> with no axe led to the precondition-guard wrapper, which went on to post the highest score of any single flag tested (+1860 XP vs a ~+1400 baseline mean).</li><li>Watching it never equip the sword sitting in its inventory led to a round of equipment experiments.</li><li>Watching it stall against invisible walls led to the spatial-perception and learned-obstacle-map work.</li></ul>
<p>Next post: the experiment framework, and which features actually helped.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
