Skip to content
Felucca Online

Docs

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:

whatfor
The protocolJSON over a WebSocketa client in any language
The Rust SDK (uo-sdk)a session with login, resume and a mirror of the world; ready-made botsRust scripts and bots
The MCP server (uo-mcp)tools such as gather, sell, walk_toClaude 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.view mirrors your area of interest from snapshots and deltas: entities, changed objects, your sheet (coin, backpack, satiety, patents), the markets you have read, the last corpse you opened, and your recent worked results. It has helpers such as at(), carried(item), coins(), room_for(item), nearest_town(..), exchange_here(..) and quote(town, item, side, amount).
  • send(cmd) sends without waiting, request(cmd) waits for the reply, next_within(d) reads one message, and wait_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's World, 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 sprite uo_proto::economy::work::node_of knows: 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:

behaviourwhat it does
miner, lumberjack, fishergather 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
hunterkills animals, loots their carcasses, sells meat and hides
haulerreads markets, buys where a good is cheap and carries it to where it is dear, if the run pays for the walk
raiderbreaks 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
guardkeeps 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) and kit::passives(school, level): what a loadout may pick.
  • kit::BuildCode: of(character), encode, decode, compare (what a code needs against what you have), and kit::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

toolwhat it does
connectlog in (server, name or API token); usually automatic
statusyour sheet: place, vitals, satiety, notoriety, weight, coin, backpack, skills, patents
lookplayers, mobs and animals with ids; resource nodes; corpses, carcasses, vaults; crafting stations; the nearest exchange
townstowns with their exchange, nearest first
walk_towalk to a town's exchange or a tile and wait to arrive
runrun in a direction for a few seconds
stopstop walking, working or fighting
gatherwood, stone, iron, gold or fish: finds nodes, walks, works them for a time or an amount
recipes, craftevery 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
eateat food from the pack
market, quoteread a town's pools; price a trade
sell, buytrade at a pool with a slippage limit; in Aquais, buy from the mainland store
deposit, withdrawa town vault
buy_patenta work patent for a skill
attack, castfight an entity or an object; cast a spell
abilityuse Q, W, E (weapon), R (body), D (helmet) or F (boots: an escape, away from the id given)
guard, dodgehold a guard; dodge a tile
loadoutthe 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_codeyour build as an FO1- code, or what a code needs that you have or lack; apply puts it on
socketput a rune into the weapon, or take one out
lootopen a corpse or carcass and take what is in it
saytalk
journalwhat happened since the last call; can wait for something to happen
send_commandany 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.