Visual assets from
How to turn a game into an RL environment: the technical intuition
One episode
Gather evidence, test a candidate on the map, then commit. Every action costs a little reward, and the pin never tells you whether you are close.
View in article →| look around | walk | bulk cache | licence | verdict |
|---|---|---|---|---|
| OSV-5M | no | no | yes | CC BY-SA 4.0 |
| Google Street View | yes | yes | no | ToS-restricted |
| Mapillary | yes | yes | yes | CC BY-SA 4.0 |
Banner
View in article →Fifteen models, before any training
Reward, accuracy at two thresholds, turn count, and the two failure modes that the reward column hides.
View in article →| tool | what it does |
|---|---|
look | Turn the camera to an absolute heading, 0 = true north. This is how it reads signage, road markings, vegetation, architecture. |
zoom | Narrow the field of view without turning. 30° reads a distant sign; 90° almost never resolves text. |
move | Walk forward or back along the road. Reports how far it actually travelled, which is not what you asked for. |
place_pin | Drop a candidate on the world map. Returns what is at that coordinate: country, subregion, nearest city with distance and bearing, and how far it is from your earlier pins. |
submit_guess | Commit. Terminal, scored on distance. |
One episode, tool call by tool call
A real recorded episode replayed from its trace. Every frame is the reprojection the model was actually served, verified against the hash in the record. The left panel is the observation, the right is the call that produced it and the reply the policy wrote.
View in article →One class in, two API surfaces out
Orange is what create_app() derives from the environment class. The trainer drives the simulation over HTTP, the policy only ever sees tools over MCP, and in production mode the simulation routes are never registered at all.
View in article →Where each piece lives, and what talks to what
Storage in a Bucket and a Dataset, the running environment on a Space with the bucket mounted read-only, GPUs rented per run through Jobs, and one HTTP URL that the trainer, the eval harness and a browser all reach.
View in article →| what | where it lives | why there |
|---|---|---|
| 22 GB of panoramas | Storage Bucket | Space disk is ephemeral and capped well below 22 GB. A bucket is mutable object storage, which suits something a harvest tops up over time, and it mounts read-only into the Space at /data. The cache can keep growing without a single commit. |
| the task indexes | dataset repo | 8 MB of metadata, and the part that has to be frozen. A bucket is not versioned, so a benchmark living in one can change under you silently; in a dataset repo a change to a split appears in history where a reviewer can see it. |
| the environment | Space | One image serves the browser game, the MCP tools and the HTTP simulation routes. Locally the indexes and imagery come from the checkout, on the Space they arrive through the mount, and nothing but environment variables differs. |
| the adapters | model repos | Both are LoRA adapters instead of merged weights, so they are tens of megabytes. Anyone can pull one, point it at the same Space, and check our numbers. |
| the GPUs | Jobs | Rented per run instead of keeping them: ten hours on four A100s, about $100. The same CLI stands up a vLLM endpoint for an eval sweep and tears it down afterwards. |
| the curves | Trackio Space | Every run in one dashboard, which is what makes them readable against each other instead of one at a time. |
What a guess is worth, and why the two curves disagree
Pick an episode or move the sliders. The dashed line is the game's curve, the solid one is what the trainer optimises, and the shaded band is where the game curve has already flattened to zero.
View in article →| bug | what it looked like |
|---|---|
| two turn budgets disagreeing | 0 of 6 episodes ever reached an answer |
done read from the observation instead of the step result | every episode unterminated, no score captured |
| a tool omitting a parameter the environment supports | a TypeError, a wasted turn, a capability hidden from the model |
Trainer to environment
Where the group is formed, where the reward is computed, and which side holds the websocket.
View in article →Run 1, from the untrained baseline
Per-step training reward with a 50-step moving average, and the held-out eval score of each checkpoint on the same axis. Both start from the untrained baseline of 0.4825.
View in article →| value |
|---|
| model |
| hardware |
| steps |
| rollouts |
| episodes |
| turns |
| image |
| optimiser |
| wall clock |
| cost |
Behaviour across checkpoints
Turns, output tokens and zero-scoring episodes all fall together while accuracy improves. Score plateaus by step 200.
View in article →| checkpoint | eval score | turns | looks | pins | output tokens | scored zero | median error |
|---|---|---|---|---|---|---|---|
| base | 0.4825 | 6.7 | 2.2 | 2.1 | 1062 | 29.5% | 1226 km |
| step 50 | 0.4968 | 4.8 | 1.5 | 1.7 | 602 | 25.4% | 1308 km |
| step 100 | 0.5600 | 2.6 | 0.6 | 0.9 | 186 | 18.5% | 964 km |
| step 150 | 0.6113 | 1.3 | 0.0 | 0.2 | 50 | 5.2% | 714 km |
| step 200 | 0.6393 | 1.1 | 0.0 | 0.1 | 46 | 1.9% | 675 km |
| step 500 | 0.6350 | 1.1 | 0.0 | 0.1 | 86 | 2.1% | 709 km |
| step 1000 | 0.6445 | 1.1 | 0.0 | 0.1 | 66 | 0.5% | 662 km |
Turns against score, every model on the board
Across the nine models we did not train, more turns go with a lower score, r = −0.75. Marker size is the share of episodes that scored zero.
View in article →The finished board
Mean-of-4 on 200 held-out tasks, 800 episodes per arm. The trained 4B is second of eleven.
View in article →| model | mean-of-4 | median error |
|---|---|---|
| claude-sonnet-5 | 0.6952 | 324 km |
| run 1, step 1000 (Qwen3.5-4B + LoRA) | 0.6445 | 662 km |
| gpt-5.4-mini | 0.5732 | 753 km |
| claude-haiku-4.5 | 0.5374 | 939 km |
| Qwen3.5-122B-A10B | 0.5338 | 767 km |
| Qwen3.5-4B, untrained | 0.4825 | 1226 km |
| Qwen3.5-9B | 0.4776 | 1203 km |
| Qwen3.5-35B-A3B | 0.4483 | 1485 km |
| Qwen3.5-27B | 0.4478 | 1289 km |
| Qwen3.5-397B-A17B | 0.4466 | 1420 km |
| gpt-5.4-nano | 0.3748 | 2541 km |
Three runs, one knob at a time
Paired gain against each run's own base, with 95% confidence intervals. Run 2 damped the instability and lost most of the gain.
View in article →| run 1 | run 2 · 4B | run 2 · 2B | run 3 |
|---|---|---|---|
scale_rewards | group | none | none |
beta | 0 | 0.02 | 0.02 |
| tasks per step | 1 | 2 | 2 |
| action cost scale | 1.0 | 0.2 | 0.2 |
| paired gain | +0.1620 | +0.0326 | +0.0663 |
| 95% CI | ±0.0137 | ±0.0090 | ±0.0091 |
Why one task per step matters
At one task per step the group standard deviation is the within-task spread, and that collapses. At two it pools between-task variance, which never does.
View in article →| bug | what it looked like | what it was |
|---|---|---|
| Wrong base served | 2B checkpoints scoring 0.469–0.479 | vLLM accepted 2B LoRAs on a 4B base and served anyway. All four scores invalid |
| Dead tunnel | ckpt75 regressing to 0.4475 | 13% of requests got an HTML 404 recorded as an empty reply, so the episode burned its turns and scored 0 |
| Two reward scales | run 2 deltas incomparable to run 1's | the stored reward is the environment's, while reports recompute through the pinned curve. The same guess scored 0.0107 and 0.135 |
| No base arm | deltas read across sweeps | the same frozen base scored 0.465–0.500 between sweeps, wider than most differences being claimed |
Comparing across k | run 1 tying Sonnet 5 | baselines read at pass@1 gave Sonnet 0.6798, at pass@4 0.6952. Mean-of-k is unbiased in k, so that gap was single-pass noise rather than a k artefact. Either way, compare arms measured with the same number of passes |
| Concurrent sweeps | k = 1.8 with PASSES=1 | two sweeps writing one directory. Arithmetically impossible, and the only tell |
The dashboard, in full
All four runs and every metric they logged, live. This is the database behind every training figure in this article.
View in article →