rs2b0t Scripting API
Bots are written in TypeScript against @rs2b0t/api — a stable, versioned
surface (apiVersion 1) over the game client. This is the complete reference.
- Every bot is a subclass of a base class registered with
defineBot.
- Scripts run inside the client.
interact()-style methods drive real input;
verify outcomes against game state with
Execution.delayUntil rather than assuming an action landed.
interact() returns boolean | Promise<boolean> (the promise form is ABI
headroom; the direct driver resolves synchronously). Always await it.
Contents
Getting started
Copy templates/script-template/ or author
in-tree under src/bot/scripts/. A script's entry module default-exports
defineBot({...}):
import { defineBot, Execution, Game, LoopingBot } from '@rs2b0t/api';
class MyBot extends LoopingBot {
override async onStart() {
await Execution.delayUntil(() => Game.ingame(), 0);
this.log('hello');
}
async loop() {
// one iteration of work
await Execution.delayTicks(1);
}
}
export default defineBot({ name: 'MyBot', create: () => new MyBot() });
Load an out-of-tree build via the panel's Load URL, or register in-tree
scripts from src/bot/scripts/index.ts.
Bot base classes
All bots extend AbstractBot (usually via LoopingBot, TaskBot, or
TreeBot).
Lifecycle hooks
abstract class AbstractBot {
loopDelay: number; // wall-clock ms between loop() iterations
readonly settings: SettingsBag; // resolved run parameters
onStart?(): void | Promise<void>; // before the first loop
onStop?(): void; // after stop AND after a crash — clean up here
onPause?(): void;
onResume?(): void;
onPaint?(ctx: CanvasRenderingContext2D): void; // overlay HUD, every redraw
log(msg: string): void;
protected on<K>(event, cb): void; // event subscription, auto-removed on stop
}
onStop runs on both a clean stop and a crash — release resources here.
- Event callbacks (
this.on) fire mid-frame: set flags / log, do real work in
loop().
LoopingBot
The common case: implement loop(). Return a number to override loopDelay for
the next iteration.
abstract class LoopingBot extends AbstractBot {
abstract loop(): number | void | Promise<number | void>;
}
TaskBot
A priority list of tasks. Each loop, the first task whose validate()
returns true has its execute() run.
interface Task {
validate(): boolean | Promise<boolean>;
execute(): void | Promise<void>;
}
abstract class TaskBot extends LoopingBot {
protected add(...tasks: Task[]): void; // usually in onStart, highest priority first
}
class Fighter extends TaskBot {
override onStart() {
this.add(
{ validate: () => Game.energy() < 20, execute: async () => { /* rest */ } },
{ validate: () => !Game.inCombat(), execute: async () => { /* attack */ } },
);
}
}
TreeBot
A behaviour tree. Walk BranchTask.validate() from root() until a LeafTask,
then run it — once per loop.
abstract class BranchTask { validate(): boolean; success(): TreeNode; failure(): TreeNode; }
abstract class LeafTask { execute(): void | Promise<void>; }
type TreeNode = BranchTask | LeafTask;
abstract class TreeBot extends LoopingBot { abstract root(): TreeNode; }
Execution
The only legal way to sleep. Awaiting anything else escapes the runtime —
Stop can't unwind it and the watchdog warns.
Execution.delay(ms: number): Promise<void> // wall-clock
Execution.delayTicks(n: number): Promise<void> // n server ticks (~600ms each)
Execution.delayUntil(cond: () => boolean, timeoutMs = 6000): Promise<boolean>
delayUntil resolves true when cond() holds (checked once per frame),
false on timeout. Use it to confirm an action landed:
const before = Inventory.used();
await item.interact('Bury');
const ok = await Execution.delayUntil(() => Inventory.used() < before, 3000);
Game
Game.ingame(): boolean
Game.tile(): WorldTile | null // local player tile, null before login/scene load
Game.energy(): number // run energy
Game.weight(): number
Game.inCombat(): boolean // health bar showing
Game.tick(): number // server ticks since client boot
Entities & queries
Four world entity types, each queried through a fluent EntityQuery:
Npcs.query(): EntityQuery<Npc>
Players.query(): EntityQuery<Player>
Locs.query(): EntityQuery<Loc> // scenery (doors, trees, rocks, stalls…)
GroundItems.query(): EntityQuery<GroundItem>
Npcs.all(): Npc[]
Npcs.nearest(count?: number): Npc[]
EntityQuery
Chainable filters; terminal methods return results.
query()
.name(...names: string[]) // case-insensitive exact match against any name
.action(action: string) // offers this action (case-insensitive)
.within(dist: number) // within dist tiles of the local player
.inside({ minX, maxX, minZ, maxZ })
.where(pred: (e) => boolean)
// terminals:
.results(): E[]
.nearest(): E | null
.first(): E | null
.exists(): boolean
.count(): number
const guard = Npcs.query().name('Guard').action('Pickpocket').within(3).nearest();
const oak = Locs.query().name('Oak').within(6).nearest();
const coins = GroundItems.query().name('Coins').within(12).nearest();
Entity shapes
All entities are Locatable (tile(): Tile, distance(): number); most are
Interactable (actions(): string[], interact(action): boolean | Promise<boolean>).
class Npc { name; level; index; inCombat; health; valid(); /* + Locatable + Interactable */ }
class Loc { name; id; /* + Locatable + Interactable */ }
class GroundItem { name; id; count; /* + Locatable + Interactable */ }
class Player { name; inCombat; /* + Locatable, actions() */ }
Note: interact() sends the action in place — it does not walk the
player to a distant target. Walk first (see Movement); the client
paths within the loaded scene.
Inventory & Equipment
Inventory.items(): InvItem[]
Inventory.first(name: string): InvItem | null
Inventory.contains(name: string): boolean
Inventory.used(): number // occupied slots
Inventory.isFull(): boolean
Equipment.items(): InvItem[]
Equipment.contains(name: string): boolean
InvItem
class InvItem {
name; id; slot; count;
actions(): string[];
interact(action: string): boolean | Promise<boolean>; // held op, e.g. 'Bury', 'Eat'
useOn(target: InvItem | Loc | Npc): boolean | Promise<boolean>;
}
useOn is "use X with Y" behind every processing skill — knife→logs,
raw fish→range, ess→altar. Returns false if a loc target is off-scene.
const raw = Inventory.first('Raw shrimps');
const range = Locs.query().name('Range').within(3).nearest();
if (raw && range) await raw.useOn(range);
Bank
Bank.isOpen(): boolean
Bank.items(): BankItemSnapshot[] // { slot, id, name, count, ops, comId }
Bank.count(name: string): number
Bank.withdraw(name: string, op?: string): boolean | Promise<boolean>
Bank.deposit(name: string, op?: string): boolean | Promise<boolean>
Bank.depositInventory(): Promise<void>
withdraw/deposit/count match names exactly. op is the context menu
label (e.g. 'Withdraw-10', 'Withdraw-All'); read the real ops off
Bank.items()[i].ops when unsure. Open a bank by interacting with a booth/banker
loc first.
Skills
Skills.index(name: string): number // lowercase name → index, -1 if unknown
Skills.level(name: string): number // base (unboosted)
Skills.effective(name: string): number // current (boosted/drained)
Skills.xp(name: string): number
ChatDialog
Drives NPC dialogs and skill "make" menus.
ChatDialog.isOpen(): boolean
ChatDialog.canContinue(): boolean // "Click here to continue" up
ChatDialog.continue(): Promise<boolean>
ChatDialog.options(): string[] // selectable option lines
ChatDialog.chooseOption(match?: string): Promise<boolean> // contains match, or first
ChatDialog.isMakeMenu(): boolean // "What would you like to make?"
ChatDialog.makeProducts(): string[]
ChatDialog.make(match?: string): Promise<boolean> // contains match at the largest fixed qty
Movement
Traversal.walkTo(dest: WorldTile, opts?: {
radius?: number; // arrive within N tiles (default 2)
timeoutMs?: number;
log?: (msg: string) => void;
}): Promise<boolean>
Traversal.preload(): void // warm the nav worker before the first walk
Traversal.remaining(): number // path tiles left in the active walk
Traversal.walkTo web-walks the whole world (A* over the collision pack + door/
transport graph, opens doors, recovers from stuck). Resolves false on
timeout/no-path; unwalkable destinations snap to the nearest reachable tile.
For same-scene clicks, DirectNavigator.walk(dest) / walkTo(dest, radius?, timeoutMs?) are available, but prefer Traversal.walkTo.
if (!await Traversal.walkTo({ x: 2662, z: 3305, level: 0 }, { radius: 0 })) {
this.log('could not reach the stall');
}
Events
Subscribe with this.on(...) inside a bot (auto-removed on stop/crash) or the
standalone events.on(...). Callbacks fire mid-frame — set flags, do work in
loop().
interface EventMap {
tick: { tick: number };
'chat.message': { type: number; username: string | null; text: string };
'skill.xp': { skill: number; name: string; xp: number; delta: number };
'skill.level': { skill: number; name: string; level: number; previous: number };
'inventory.changed': { slot: number; id: number; name: string | null; count: number; previousId: number; previousCount: number };
'varp.changed': { index: number; value: number; previous: number };
}
this.on('skill.xp', e => { if (e.name === 'prayer') this.xp += e.delta; });
Settings
Declare a settingsSchema on the manifest; it renders as a form in the panel and
is overridable per-run via ?ScriptName.key=value in the URL. Read values at
runtime through this.settings.
type SettingType = 'boolean' | 'number' | 'string' | 'string[]' | 'tile';
interface SettingDef { type: SettingType; default: unknown; label?; min?; max?; help?; }
type SettingsSchema = Record<string, SettingDef>;
interface SettingsBag {
bool(key, fallback?): boolean;
num(key, fallback?): number;
str(key, fallback?): string;
list(key, fallback?): string[];
tile(key, fallback: Tile): Tile;
raw(): Record<string, unknown>;
}
export default defineBot({
name: 'Miner',
settingsSchema: {
rock: { type: 'string', default: 'Copper rocks', label: 'Rock' },
world: { type: 'boolean', default: true, label: 'World-hop when crowded' },
},
create: () => new Miner(),
});
// in the bot: const rock = this.settings.str('rock', 'Copper rocks');
World primitives
interface WorldTile { x: number; z: number; level: number; }
class Tile implements WorldTile {
constructor(x: number, z: number, level?: number);
static from(tile: WorldTile): Tile;
distanceTo(other: WorldTile): number; // Chebyshev (game movement metric)
translate(dx: number, dz: number): Tile;
equals(other: WorldTile): boolean;
}
abstract class Area {
static rectangular(a: WorldTile, b: WorldTile): Area;
static circular(center: WorldTile, radius: number): Area;
contains(tile: WorldTile): boolean;
getRandomTile(): Tile;
}
Item acquisition
Higher-level helpers for "make sure I have these items":
type ItemNeed = { name: string; count: number; source: ItemSource };
held(name: string): number // count of an item across backpack slots (worn gear NOT included)
hasAll(needs: ItemNeed[]): boolean // every need satisfied by current holdings
class AcquireTask implements Task { constructor(bot, needs: ItemNeed[]); } // obtains items
AcquireTask plugs into a TaskBot to gather/shop/withdraw a set of item needs.
See src/bot/api/ItemAcquisition.ts and the bots that use it for usage.
Registering a bot
interface BotManifestInput {
name: string;
description?: string;
version?: string;
category?: string; // filter chip in the library (e.g. "Mining")
tags?: string[]; // free-form search labels
settingsSchema?: SettingsSchema;
create(): AbstractBot;
}
function defineBot(manifest: BotManifestInput): BotManifest; // default-export this
function registerScript(manifest: BotManifestInput, origin?: string): void; // imperative
Default-export defineBot({...}) from your entry module. The URL loader calls
registerScript for you; in-tree scripts are registered from
src/bot/scripts/index.ts.
Full example
The out-of-tree template (templates/script-template/src/ExampleBot.ts):
loots and buries bones, tracks prayer xp via events, and draws a HUD.
import { defineBot, Execution, Game, GroundItems, Inventory, LoopingBot } from '@rs2b0t/api';
class BoneBurier extends LoopingBot {
private buried = 0;
private xpGained = 0;
override async onStart(): Promise<void> {
await Execution.delayUntil(() => Game.ingame(), 0);
this.log('BoneBurier started');
this.on('skill.xp', e => { if (e.name === 'prayer') this.xpGained += e.delta; });
// an emptied slot reports id -1 with the previous item id — a completed burial
this.on('inventory.changed', e => {
if (e.id === -1 && e.previousId !== -1) {
this.buried++;
this.log(`buried bones (#${this.buried})`);
}
});
}
async loop(): Promise<void> {
const bones = Inventory.first('Bones');
if (bones) {
const before = Inventory.used();
await bones.interact('Bury');
await Execution.delayUntil(() => Inventory.used() < before, 3000);
return;
}
const ground = GroundItems.query().name('Bones').within(10).nearest();
if (ground && !Inventory.isFull()) {
const before = Inventory.used();
await ground.interact('Take');
await Execution.delayUntil(() => Inventory.used() > before, 5000);
return;
}
await Execution.delayTicks(2);
}
override onStop(): void {
this.log(`stopped — ${this.buried} buried, +${this.xpGained} prayer xp`);
}
override onPaint(ctx: CanvasRenderingContext2D): void {
ctx.font = '12px monospace';
ctx.fillStyle = '#ffb15b';
ctx.fillText(`BoneBurier buried ${this.buried}`, 12, 22);
}
}
export default defineBot({
name: 'BoneBurier',
version: '0.1.0',
description: 'External example: loots and buries nearby bones',
create: () => new BoneBurier(),
});