# Teaching a Bot to Walk

https://jaredrhodes.com/blog/teaching-a-bot-to-walk/

A bot that fights well and cannot walk is useless. A bot that walks badly is worse than useless, because badly is visible from across the zone.

Movement is the single largest source of engineering debt in this project, and it earned that position honestly.

## Movement Is Not One Problem

Movement in a 20-year-old MMO client is four problems, and they fail differently.

{: data-caption="Four movement problems, four ways to recognize each one"}
| Problem | Failure signature |
| --- | --- |
| **Where can I walk?** | The bot routes through a wall, or refuses a corridor that is obviously open |
| **What is the route?** | The bot arrives, eventually, having taken the scenic path through a murloc camp |
| **What happens when I move?** | The bot floats, sinks into terrain, climbs a cliff the client would refuse, or falls through the world |
| **Is the server going to accept this?** | The server rubber-bands the character back, because the movement packets described something impossible |

The first two are search problems with well-understood solutions. The third is a reimplementation problem with no shortcuts. The fourth is a protocol problem that only shows up under load.

Most bot projects solve one and a half of these and then spend years patching symptoms. I have watched it happen, and the interesting decision in this codebase is where the fix is allowed to go.

## Who Owns World Geometry

<figure class="diagram">
  <a href="/assets/diagrams/westworld-of-warcraft/navigation-stack.svg" target="_blank" rel="noopener" title="Open the full-size diagram"><img src="/assets/diagrams/westworld-of-warcraft/navigation-stack.svg" width="1000" height="560" alt="Navigation stack: offline build takes client map data through the mesh generator into generated navigation tiles, runtime services provide Detour routing with a route pack cache plus a scene data service and ported physics engine, and the bot side runs the universal movement task through a movement controller emitting heartbeat opcodes, with a freeze rule stating mesh problems are fixed in the generator" loading="lazy" decoding="async" data-theme-filter="off" /><span class="visually-hidden"> (opens in a new tab)</span></a>
</figure>

Offline, `tools/MmapGen` reads the client's terrain, world models, and doodads, and produces navigation mesh tiles with Recast. At runtime, `PathfindingService` runs Detour A\* over those tiles, with route-pack caching and a startup prewarm; `SceneDataService` serves local collision slices and walkable-ground queries; `PhysicsEngine.dll` simulates movement the way the client does. On the bot side, `GoToTask` hands off to a `MovementController` that consumes the route, emits movement opcodes, and detects being stuck.

The two services are the **only** owners of world-geometry answers. Bot code never loads a mesh tile or a collision volume. That single rule is what keeps geometry consistent across three thousand bots instead of three thousand slightly different opinions about a hill.

The pathfinding service is stateless per request but warms its cache at startup, and it does a few things that only matter at scale:

- Route packs: cached routes with provenance for high-traffic legs, prewarmed from the catalog's hot paths.
- Request de-duplication, so forty bots asking for the same route at reset time produce one search.
- Cancellation and deadline propagation, so a bot that changed its mind does not leave a search running.
- Batch estimates, so the scheduler can score candidate bots by travel time without materializing full paths.

That last one is the difference between "pick the closest bot" being a nice idea and being affordable.

## The Freeze

At one point, the managed side of pathfinding contained roughly 5,600 lines of route-repair logic. Special cases. Post-processing. Detection for "this route is wrong, patch it."

Every one of those lines was written for a real bug. Collectively they were a disaster.

Route repair is a trap with a specific shape. A mesh has a hole. A bot falls in the hole. Rather than fix the mesh - which requires rebuilding tiles, which is slow and needs the client data - you write code that notices the bot is in the hole and lifts it out. It works. You ship it. Two weeks later a different hole needs a different repair, and the repair interacts with the first one, and now you have a system where the answer to "why did the bot go there" is 5,600 lines deep. I know because I built exactly that system once.

The freeze is the correction:

{: data-caption="The freeze: what is banned, and where fixes must land"}
| Rule | Effect |
| --- | --- |
| No new repair phases in the managed navigation repository | The old pipeline stops growing |
| No route-specific scripts in the behavior engine | Movement stays generic |
| No per-location route tests | Tests stop encoding workarounds as requirements |
| No bot-side jump-up for regular pathing | If the mesh says you cannot get there, you cannot get there |
| **Mesh problems get fixed in the generator's per-tile configuration** | The fix lands where the defect is |

The last row is the whole policy. If the mesh is wrong, the mesh is wrong. Regenerate the tile with corrected parameters. It is slower to iterate on and it is the only thing that converges.

The per-location test ban deserves a note. A test named `CanPathFromOrgrimmarToCrossroads` looks like a good test. It is actually a hostage. Once it exists, the fastest way to make it pass is a special case, and now the special case has a test defending it. General navigation quality is asserted by whether bots complete activities at all.

## Physics Is a Port, Not a Design

Here is the sentence that reframes the whole problem: **the game client is the authority.**

The client's compiled behavior *is* the specification, not a reference to aim near. The background runtime's physics library exists to reproduce it.

<figure class="diagram">
  <a href="/assets/diagrams/westworld-of-warcraft/physics-authority.svg" target="_blank" rel="noopener" title="Open the full-size diagram"><img src="/assets/diagrams/westworld-of-warcraft/physics-authority.svg" width="960" height="500" alt="Physics authority chain: the game client is the movement and collision authority, the foreground bot runs inside it and is ground truth, packet capture is observation only, decompiled routines and the address parity map feed binary-backed implementation rows in the ported physics engine, each closed by a focused canary, with tolerance guard bands reserved for smoke checks" loading="lazy" decoding="async" data-theme-filter="off" /><span class="visually-hidden"> (opens in a new tab)</span></a>
</figure>

The client uses closed-form movement state plus two-pass swept collision against an axis-aligned bounding box. It does not use a modern character controller, and it does not use anything a physics middleware would give you for free. Reaching for a general-purpose physics engine here would produce movement that is *better* than the client's and therefore wrong.

The constants are not tuning parameters. They are values read out of the client binary, and every one of them has a routine and an address behind it in the parity map:

- **Falling** - gravity 19.29110527 y/s^2, terminal velocity 60.14800262 y/s, safe-fall terminal velocity 7.0 y/s, jump initial velocity 7.955547 y/s.
- **Ground movement** - forward run 7.0 y/s, backward run 4.5 y/s.
- **Collision** - step height 2.027778 y, collision skin 0.333333 y, walkable slope cos(50 degrees) = 0.6428.

Ten significant figures on gravity is not precision theater. It is the difference between a jump arc that matches and a jump arc that accumulates error until the server disagrees with the client about where the character is.

### What Counts as Proof

Because the port has an authority, it also has a strict definition of done for each piece:

{: data-caption="What closes a port row, and what is only observation"}
| Evidence | Status |
| --- | --- |
| A decompiled routine, its address, the source surface it touches, and a canary that fails without the fix | **Closes an implementation row** |
| A managed canary on the packet or control surface feeding the native layer | **Closes a boundary row** |
| A real map probe when a geometry-producing branch needs map-backed data | **Valid supporting evidence** |
| A recorded session replaying without visible error | Observation only |
| A test asserting that a named route completes | Not proof |
| A screenshot of a bot in the right place | Not proof |

Foreground packet tracing stays useful - it is how you notice a divergence exists. It just does not close anything. A capture says "these two runs differ." A canary says "this routine differs, here, and now it does not."

Guard bands exist too, and they are scoped to smoke checks: settle Z within +/-0.3 y, steady-state walk speed within +/-0.1 y/s, jump apex within +/-0.05 y, fall time over a fixed drop within +/-0.05 s.

Two signals get no tolerance at all. The slope-climb threshold and the step-up height match the binary exactly or the row stays open, because slope and step are where "close enough" turns into a bot standing on a rock it should not be able to reach, in a spot no human has ever occupied, in full view of anyone walking past.

## Blame in This Order

When movement goes wrong, the diagnosis order is fixed. It exists because the natural instinct - blame the pathfinder - is usually wrong.

1. **Scene data first.** Does the collision service actually deliver geometry for that coordinate? A missing tile produces the exact signature of a physics bug.
2. **Physics second.** If physics is implicated, reduce it to a focused native canary backed by binary evidence. Do not iterate on routes while suspecting physics.
3. **Pathfinding third.** Only after the first two are clean: does Detour produce a route, and does the bot consume it correctly?

Skipping to step three is how projects end up with 5,600 lines of repair code. The route looked wrong, so the route got patched, and the actual defect was two layers down.

## Why the Tauren Is on the Cliff

The movement failure I care most about is not the spectacular one. Falling through the world announces itself. This one arrives on time.

1. A background bot is asked to walk from Kargath to the Blackrock Mountain entrance.
2. The generated mesh marks a steep scree slope as walkable because the tile was generated with a generous agent slope.
3. Detour finds the short route straight up the slope. It is legal on the mesh.
4. The ported physics accepts the climb, because the slope check has a rounding difference.
5. The bot arrives 40 seconds early, having climbed something the real client would slide off.
6. The task verifies, the snapshot is clean, and no counter anywhere moves.

The bot is now standing somewhere no player has stood, and the only evidence is that its arrival times are suspiciously good. Then a human watches a replay and asks why the tauren is on the cliff.

Every other response follows from the same two ideas: fix it where the defect is, and diagnose downward before diagnosing sideways. A bot routing through terrain means regenerating the tile, never adding a repair phase. A bot climbing something the client would refuse means a slope-threshold canary against the binary. A bot stuck in a doorway means checking scene-data delivery before anyone touches the route. A character the server rubber-bands is a movement-protocol problem before it is a physics one. A long but valid route gets left alone, because scenic is not broken. And when one favorite route regresses, the answer is still not a test for that route.

## A Stack That Stops Growing

The scale criteria are the easy half, and they are mostly about the pathfinding service behaving like a service: it de-duplicates identical in-flight requests, it honors deadlines, and its batch travel estimates stay cheap enough to score candidate bots without materializing a single full route. No bot code anywhere loads a mesh tile or a collision volume for itself.

The criteria I actually watch are about where fixes land. Every closed physics row cites a specific routine in the client binary and carries a canary that fails without the fix. Slope-climb and step-up match the binary exactly. Mesh defects get fixed by regenerating tiles, and the managed repair pipeline does not grow - which is the one I can check with `git log` rather than a test run. If that line count starts climbing again, none of the other criteria matter, because I will be right back in the 5,600-line hole with a fresh set of reasons for every line of it.

## Related Posts

The universal movement task that consumes all of this is described in [Activity, Objective, Task, Action](/blog/activity-objective-task-action/). The runtime that owns the physics port is in [Two Ways to Wear a Character](/blog/two-ways-to-wear-a-character/). Route noise is later added deliberately, per bot, in [The Cast](/blog/personality-as-a-function-of-a-name/).

## References

- [Recast and Detour navigation toolkit](https://github.com/recastnavigation/recastnavigation)
- [Recast navigation mesh generation](https://recastnav.com/)
- [Detour crowd and pathfinding overview](https://github.com/recastnavigation/recastnavigation/blob/main/README.md)
- [Swept AABB collision detection](https://www.gamedeveloper.com/programming/swept-aabb-collision-detection-and-response)
- [A\* search algorithm](https://ieeexplore.ieee.org/document/4082128)
