Scripting and AI agents
Decision #3 made automation part of the game: there is one public protocol for the game client, scripts and agents alike, and a bot is an ordinary player. Decision #121 calls it smart idle: a script may work your character while you are away. Decision #132 adds the MCP server, so a language model can play a character through tools.
There are three ways in, from the most control to the least code:
| what | for | |
|---|---|---|
| The protocol | JSON over a WebSocket | a client in any language |
The Rust SDK (uo-sdk) | a session with login, resume and a mirror of the world; ready-made bots | Rust scripts and bots |
The MCP server (uo-mcp) | tools such as gather, sell, walk_to | Claude and other LLM agents |
The rules for bots
Bots play by the same rules as people. Nothing here is a privileged API.
- The server decides everything. Clients send intents (walk there, chop that tree, sell 20 logs, no worse than 70 coin) and the server checks reach, skills, patents, weight, notoriety and prices. A bot that cheats only gets refusals.
- The same speeds. A step takes 250 ms out of a fight and 500 ms in one, a chop 2 s, a swing its weapon's time. Sending commands faster gains nothing; the server queues or refuses them.
- The same limits. Rate limits per connection and per account apply to every client, and the same goes for one character per session and one session per character. Expect polite refusals if you flood.
- The same risks. A bot carries real coin, can be robbed, dies and leaves a corpse. Raids, guards and grey thieves treat it like anyone.
- Prices are public. Any character at an exchange can read its market; bots that trade better than people are part of the design.
Treat a refusal as information. The economy bots back off and remember (the node is spent, the vault is outside its raid window, the pool won't fill at that price), and yours should do the same.
A server to play on
For development, run your own server. It keeps everything in memory and logs any name straight in:
cargo run --release -p uo-server
It listens on ws://127.0.0.1:7777 and plays a generated 1024 × 1024 map. To play Felucca itself,
pass the baked world, in which every new character lands in Aquais:
cargo run --release -p uo-server -- --world assets/world/felucca
Servers with accounts (#126; dev login off, the default off --store mem) sign scripts in with an
account's API token instead of a bare name: make one in the client's character screen (API
token) or with the create_api_token command, then sign_in_token and play_character
(uo_sdk::Auth::Token, or Session::with_token, which plays the account's first character when
none is named). The MCP server takes it as --token / UO_TOKEN and plays the first character.
The Rust SDK
crates/uo-sdk is the library the load-test swarm and the game client's --bot mode use.
Session::login(&urls, name)connects to the first reachable gateway and logs in;reconnect()resumes on the next gateway after a dropped connection, using the token from the welcome.session.viewmirrors your area of interest from snapshots and deltas:entities, changedobjects, yoursheet(coin, backpack, satiety, patents), themarketsyou have read, the last corpse you opened, and your recentworkedresults. It has helpers such asat(),carried(item),coins(),room_for(item),nearest_town(..),exchange_here(..)andquote(town, item, side, amount).send(cmd)sends without waiting,request(cmd)waits for the reply,next_within(d)reads one message, andwait_for(d, |view| …)reads until a condition holds. Every message read is applied to the view first.world_of(&welcome, dir)gives you the server'sWorld, so you can read the map the way the server does:walkable, the towns (uo_world::towns), and each region's objects with their keys (cached_content). A resource node is any object whose spriteuo_proto::economy::work::node_ofknows: trees, iron and gold veins, quarry stone. Water tiles are ground nodes (work::tile_node); bare rock gives nothing. Trees, rocks and veins break when worked out and come back later (ObjectUpdate { broken, look }): skip broken objects (#133).
The template
crates/uo-sdk/examples/template.rs is a complete lumberjack in about a hundred lines: it eats when
hungry, chops the nearest tree, walks ten logs to the nearest exchange and sells them with a 3 %
slippage limit. Copy it to start your own:
cargo run -p uo-sdk --example template -- ws://127.0.0.1:7777 Woody
The core of the loop, one decision at a time:
let mut s = Session::login(&[url], "Woody").await?;
let world = world_of(s.view.welcome.as_ref().unwrap(), None)?;
s.request(Command::Sheet).await?;
loop {
let at = s.view.at().unwrap();
if s.view.carried(ids::LOG) >= 10 {
// walk to the nearest exchange, read its market, sell with a limit
} else if s.view.sheet.as_ref().is_some_and(|sh| sh.task.is_some()) {
s.next_within(Duration::from_millis(500)).await?; // still chopping
} else {
// nearest tree → a free tile next to it → Harvest { target: Object { key } }
}
}
In a project of your own, depend on the crates by path while the repository is private:
[dependencies]
uo-sdk = { path = "../felucca/crates/uo-sdk" }
uo-proto = { path = "../felucca/crates/uo-proto" }
uo-world = { path = "../felucca/crates/uo-world" }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
The economy bots
uo_sdk::bot holds the behaviours the load tests run (crates/uo-sdk/src/bot/econ.rs). They are
the best examples of playing the economy well, and you can run them as they are:
| behaviour | what it does |
|---|---|
miner, lumberjack, fisher | gather until the pack is full or a batch is in it, sell at the nearest exchange, buy food, renew patents, buy a new tool in Aquais when one breaks |
hunter | kills animals, loots their carcasses, sells meat and hides |
hauler | reads markets, buys where a good is cheap and carries it to where it is dear, if the run pays for the walk |
raider | breaks a vault in its town's raid window, plunders coin, takes it to a lawless town's fence or hides until the taint wears off |
guard | keeps a town's exchange, kills grey players, loots them and returns stolen goods for the reward |
let mut brain = Brain::new(Behaviour::Lumberjack, world, seed);
brain.tuning.sell_batch = Some(20); // sell every 20 logs
play(&mut session, &mut brain, Duration::from_secs(3600), |_| false).await?;
Or with the template: cargo run -p uo-sdk --example template -- ws://127.0.0.1:7777 Woody lumberjack. To watch a crowd of them, the load tester runs any mix:
cargo run --release -p uo-loadtest -- swarm --bots 50 --mix "miner=20,lumberjack=20,hauler=10"
Their structure is worth copying. They make at most one decision per think tick, send nothing while a request is unanswered, back off on a refusal (doubling, up to 30 s), wait for the sheet that shows a change before deciding again, and keep a short memory of what the world said no to.
Builds and fighting bots
The build system (decisions #147–#150, builds.md) is all ordinary protocol: a
character's six ability keys are ability { slot, target }, and its picks change with set_loadout
out of a fight: Q and W from the weapon family's pools, R, D and F from the whole chest, helmet and
boots pools (decision #167: any armour class offers every one; a pick works while a piece is worn
in that slot), and passives; gear with equip, runes with socket and unsocket, crafting with
craft_with { material, design, affix }. The SDK reads the rest for you (uo_sdk::kit):
let kit = session.view.kit().expect("the sheet arrived"); // Q W E R D F, counters, states
for key in &kit.keys {
if let Some(d) = key.def() {
println!("{}: {} ({} ms to ready)", key.slot.key(), d.name, key.ready_in_ms);
}
}
if kit.controlled() && session.view.ability_ready(Slot::F) { /* Break Free */ }
let code = session.view.build_code().unwrap().encode(); // "FO1-…" to share
let l = uo_sdk::kit::loadout_with(&kit_sheet.loadout, &["cleave", "lunge", "bulwark", "roll"], Some(&["duelist"]))?;
session.request(Command::SetLoadout { loadout: l }).await?;
View::kit(): the keys with their abilities and the ms until each is ready (from the server's cooldown times and the clock the view keeps,View::server_ms), the weapon family, school level, passives, worn triggers, keystone, armour pieces per class, the crit and block counters (percent towards the next one: no dice) and the states on the character with their time left.kit::pool(family, slot, level)andkit::passives(school, level): what a loadout may pick.kit::BuildCode:of(character),encode,decode,compare(what a code needs against what you have), andkit::wear_code(the commands that put it on).kit::use_of(ability): what a key is for (heal, escape, break control, buff, strike), which is how the bots choose.
The fighting bots (combat, churn, duel, siege, party and the role bots) use the six keys
through bot::fight::Fight. Role bots accept party invitations, and Tuning::party forms one
(its first name invites the rest). The policy: break control with Break Free, Battle Focus or Cleanse; when badly hurt
a heal, then a shield or buff R, then the F escape away from melee, then a potion; a healer heals the
most hurt ally in reach; the D as an opener on each foe, a timed R once melee is close, E on a
broken or weakened foe, Q and W whenever ready and affordable. Never the same key twice within a
second, so a refused key never floods the gateway.
Role bots play one of the catalogue's example builds (uo_sdk::roles): tank (Iron Wall:
mace, tower shield, full plate), archer (Line Archer: longbow, leather; keeps out of melee with
its escape), healer (Field Medic: holy staff and tome in chainmail; keeps with its party and
heals it: heals land only on party members) and bladestorm (dagger, poison and blades in leather). A role bot
puts on what of its build its pack holds and sets the loadout (the catalogue's Q, W, R, D and F
picks, data::EXAMPLE_BUILDS), then hunts. RoleBuild::character
makes a character dressed in it at any tier for tests and dev servers; crates/uo-sdk/tests/roles.rs
has each role win its fight against tier 1 bandits on a real node (the healer keeping a tank
alive).
cargo run --release -p uo-loadtest -- swarm --bots 20 --mix "tank=5,archer=5,healer=5,bladestorm=5"
AI agents over MCP
uo-mcp is a Model Context Protocol server on stdio. Each
instance plays one character. It logs in through the SDK and turns whole small tasks into tools:
walk there and wait, find trees and chop until twenty logs are in, quote and sell with a limit.
Between tool calls it keeps reading the game, and every answer ends with what happened since the
previous call (who hit you, chat, work results, hunger, notoriety). The agent can react without
polling.
Build it:
cargo build --release -p uo-mcp
Connecting Claude Code
claude mcp add felucca -- /path/to/felucca/target/release/uo-mcp --server ws://127.0.0.1:7777 --name Ann
Or in a project's .mcp.json, or Claude Desktop's claude_desktop_config.json:
{
"mcpServers": {
"felucca": {
"command": "/path/to/felucca/target/release/uo-mcp",
"args": ["--server", "ws://127.0.0.1:7777", "--name", "Ann"]
}
}
}
Any MCP client that runs stdio servers works the same way. The options can also come from the
environment: UO_SERVER, UO_NAME, UO_TOKEN (an account's API token, for servers with
accounts) and UO_WORLD_DIR. Without them, the agent logs in with the connect tool. When the
server plays a baked world, uo-mcp reads the same bake to know the towns and the resource nodes:
it looks in ./assets/world/<name> and in the repository's assets/world, or wherever
--world-dir points.
Then ask, for example: "You are Ann, a colonist in Felucca. Earn 100 coin as a lumberjack: chop wood near Aquais, sell it at the exchange, and keep yourself fed."
Tools
| tool | what it does |
|---|---|
connect | log in (server, name or API token); usually automatic |
status | your sheet: place, vitals, satiety, notoriety, weight, coin, backpack, skills, patents |
look | players, mobs and animals with ids; resource nodes; corpses, carcasses, vaults; crafting stations; the nearest exchange |
towns | towns with their exchange, nearest first |
walk_to | walk to a town's exchange or a tile and wait to arrive |
run | run in a direction for a few seconds |
stop | stop walking, working or fighting |
gather | wood, stone, iron, gold or fish: finds nodes, walks, works them for a time or an amount |
recipes, craft | every recipe, build gear included, with its materials (tiers), the affix designs that fit Hands, Arms and Legs and the second affixes; craft at a station, with a material, design and affix |
eat | eat food from the pack |
market, quote | read a town's pools; price a trade |
sell, buy | trade at a pool with a slippage limit; in Aquais, buy from the mainland store |
deposit, withdraw | a town vault |
buy_patent | a work patent for a skill |
attack, cast | fight an entity or an object; cast a spell |
ability | use Q, W, E (weapon), R (body), D (helmet) or F (boots: an escape, away from the id given) |
guard, dodge | hold a guard; dodge a tile |
loadout | the six keys with cost, cooldown and when ready; the family's pools; passives; triggers; counters; states; sets Q, W, R, D, F and passives |
build_code | your build as an FO1- code, or what a code needs that you have or lack; apply puts it on |
socket | put a rune into the weapon, or take one out |
loot | open a corpse or carcass and take what is in it |
say | talk |
journal | what happened since the last call; can wait for something to happen |
send_command | any protocol command as JSON (guard, potion, equip, mint, export…) |
The journal speaks the build system too: states on you or put on by you and when they wear off, reactions you set off, your clothing, jewels and keystone firing, escapes, formations, and with every hit whether it crit or was blocked and how far your crit or block counter stands.
Every answer has a plain-words text for the model and the numbers in structuredContent for
programs. A refusal comes back as an error with the server's own sentence ("You need a
Lumberjacking patent: buy one at any exchange.").
Resources: felucca://character, felucca://surroundings, felucca://towns,
felucca://markets (the last view of each market read), felucca://journal (the last 100 lines),
and felucca://catalog (items, recipes, goods and the mainland store).
The proof that it works is crates/uo-mcp/tests/proof.rs. It starts a server, launches uo-mcp,
and plays the model's part over stdio: land near Aquais, chop wood, sell it at the free port's
pool, buy bread from the mainland store, and eat.
cargo test -p uo-mcp --test proof
Other languages
Anything that speaks WebSocket and JSON can play. The protocol reference lists every command, reply and event with a JSON example. A minimal Python client:
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:7777") as ws:
await ws.send(json.dumps({"req": 1, "type": "login", "name": "Pyra"}))
await ws.send(json.dumps({"req": 2, "type": "sheet"}))
async for frame in ws:
msg = json.loads(frame)
if msg["type"] == "reply" and msg["req"] == 1:
w = msg["data"]
print("in at", w["x"], w["y"], "protocol", w["protocol"])
elif msg["type"] == "event" and msg["event"] == "sheet":
coins = sum(i["amount"] for i in msg["sheet"]["items"].values() if i["def"] == 8)
print("coin:", coins)
asyncio.run(main())
What you will have to rebuild without the SDK: the view (apply snapshot, then each delta's
enter, update and leave), and the map, towns and resource nodes, which the SDK reads from
the world. Until those are published as data, a script in another language learns about nodes by
walking and asking, or calls uo-mcp as a library of tools.