12 KiB
Bot Scripting Guide (player self-botting)
This documents the system that lets a player automate their own account — i.e. hand their logged-in character over to an in-game script that plays for them. This is the area to expand when adding new automation scripts.
The two kinds of "bots" — don't confuse them
The core.game.bots package serves two related but distinct purposes:
- AI players (
AIPlayer) — fake, server-spawned characters used to populate the world (PvMBots,CombatBot, the various assemblers/builders). They are not real accounts. - Player self-botting — a real logged-in
Playerruns aScripton their own character via the::scriptcommand. This guide is about #2.
Both reuse the same Script base class and ScriptAPI, which is why they live together.
How a player runs a script on their own account
Implemented in core.game.system.command.sets.BottingCommandSet (a STANDARD-privilege
command set). All three commands early-return unless botting is enabled in config:
::scripts— opens an interface listing every registered player-compatible script: its name, description lines, and the::script <identifier>needed to start it. On first use it shows a red warning that running a script removes the player from the highscores; the player must acknowledge (sets the saved attributebotting:warning_shown) before the list appears.::script <identifier>— looks up the script inPlayerScripts.identifierMap, closes interfaces, and starts it on the calling player viaGeneralBotCreator(script.newInstance() as Script, player, isPlayer = true). Also shows the highscores warning on first use.::stopscript— retrieves the running pulse from the player'sbotting:scriptattribute and stops it, closing the overlay. (Logging out also stops the script.)
Starting a script sets /save:not_on_highscores = true on the player (the permanent
highscore removal) and stashes the running BotScriptPulse under the botting:script
attribute.
Enabling the feature
Off by default. In Server/worldprops/default.conf:
enable_botting = false # set true to expose ::scripts / ::script / ::stopscript
Config flow: default.conf world.enable_botting →
ServerConfigParser → GameSettings.enabled_botting → read by BottingCommandSet.
(Note the config key is enable_botting; the parsed field is enabled_botting.)
How scripts are discovered and registered
At startup core.plugin.ClassScanner finds every class annotated with
@PlayerCompatible and registers it into PlayerScripts.identifierMap, keyed by the
script's @ScriptIdentifier. Each entry is a
PlayerScripts.PlayerScript(identifier, description, name, clazz).
So a script becomes available to players simply by existing with the right annotations — no registry edit needed.
Anatomy of a script
Player-runnable scripts live in Server/src/main/content/global/bots/ and extend
core.game.bots.Script. Required annotations:
| Annotation | Meaning |
|---|---|
@PlayerCompatible |
Marks the script as runnable on a real player account (required for ::script) |
@ScriptName("...") |
Display name in the ::scripts list |
@ScriptDescription("line1", "line2", ...) |
Description lines shown in the list |
@ScriptIdentifier("snake_case_id") |
The id used in ::script <id> and the map key |
The base class (Script.java) gives you:
bot: Player— the character the script controls (for self-botting, the real player).scriptAPI: ScriptAPI— the automation helper API (created ininit).inventory/equipment/skills/quests— starting loadouts, only applied for AI players (isPlayer == false); skipped when a real player runs the script.abstract fun tick()— called each pulse to advance the script. This is where all your logic goes.newInstance()— currently required to compile but effectively unused (the transition pulse that called it is dead code); returnthis.
The execution model
GeneralBotCreator wraps the script in a BotScriptPulse (a Pulse(1), i.e. runs each
tick) submitted to GameWorld.Pulser. Each tick, pulse():
- If a random delay is pending, decrements it and skips (simulates human hesitation).
- If the bot is stuck mid-
MovementPulsefor >5 ticks, cancels the movement. - If a modal/dialogue is open and
endDialogue == true, auto-closes it to avoid a deadlock where the authentic interaction subsystem waits on input that never comes. (SetendDialogue = falsein your script if you need dialogue to persist — e.g. boat travel.) - Only when the bot has no pulse running and no active script action does it call
your
tick(). There's also a per-tick global cap (botPulsesTriggeredThisTick, max 75) and a ~10% chance to insert a random idle delay (skipped forIdler).
Practical implication: write tick() as a state machine. Kick off one action
(walk, attack, interact), let the resulting pulse run, and advance your state on the next
tick() when the bot is free again. See ChickenKiller below.
Reference example — ChickenKiller
content/global/bots/ChickenKiller.kt is the canonical minimal example. Shape:
@PlayerCompatible
@ScriptName("Chicken Killer")
@ScriptDescription("Kills chickens and loots feathers. Start in any chicken area.")
@ScriptIdentifier("chicken_killer")
class ChickenKiller : Script() {
var state = State.INIT
var overlay: ScriptAPI.BottingOverlay? = null
override fun tick() {
when (state) {
State.INIT -> {
overlay = scriptAPI.getOverlay()
overlay!!.init(); overlay!!.setTitle("Chickens")
overlay!!.setTaskLabel("Chickens KO'd:"); overlay!!.setAmount(0)
// ask the player a config question via dialogue, then advance state
state = State.CONFIG
}
State.KILLING -> {
val chicken = scriptAPI.getNearestNode("Chicken")
if (chicken == null) scriptAPI.randomWalkTo(startLocation, 3)
else scriptAPI.attackNpcInRadius(bot, "Chicken", 10)
}
// ... LOOTFEATHER / LOOTBONES / BURYBONES states ...
}
}
override fun newInstance(): Script = this
enum class State { INIT, CONFIG, KILLING, IDLE, LOOTFEATHER, LOOTBONES, BURYBONES }
}
Other player-compatible scripts to crib from (all in content/global/bots/):
CoalMiner, LobsterCatcher, SharkCatcher, NatureCrafter, LawCrafter,
CosmicCrafter, GnomeAgility, GnomeBowstring, SeersFlax, SeersMagicTrees,
VarrockEssenceMiner, DraynorWillows, CannonballSmelter.
AI-world bots vs. player self-bots (and converting between them)
The content/global/bots/ directory holds two kinds of scripts that share the exact
same Script base class, tick(), and ScriptAPI — they differ only in how they're
launched and bodied:
- AI-world bots — server-spawned fake
AIPlayers used to make the world feel populated. Launched at startup bycore.game.world.ImmerseWorld(aStartupListener, gated byenable_bots/max_adv_botsconfig) viaGeneralBotCreator(script, location)— thebotis a fabricatedAIPlayerwhose gear, stats, and inventory are injected bySkillingBotAssembler/CombatBotAssembler. These are the ones without@PlayerCompatible(e.g.CowKiller,ManThiever,FarmerThiever,VarrockSmither,GreenDragonKiller,DraynorFisher,Idler,DoublingMoney). - Player self-bots — launched by
::scriptviaGeneralBotCreator(script, player, isPlayer = true). Thebotis the real player, andScript.init()skips theinventory/equipment/skills/questsinjection (that only runs whenisPlayer == false).
Converting an AI-world bot into a player self-bot is usually straightforward because the core logic is identical. Steps:
- Add the four registration annotations (
@PlayerCompatible,@ScriptName,@ScriptDescription,@ScriptIdentifier). This alone lists it in::scripts. - Remove reliance on assembler-injected gear/stats — the real player brings their own. Anything the AI version assumed it was handed (pickaxe, runes, armor, food) must be checked / withdrawn from bank / bought on the GE, or stated as a start requirement in the description lines.
- Drop fixed spawn-location assumptions — a player runs from where they stand. The
newInstance()overrides that build a freshAIPlayerat a spawn zone are irrelevant in player mode (and that respawn path is dead code); justreturn this. - Sanity-check that the behavior makes sense on a real account. Clean candidates:
VarrockSmither,NonBankingMiner,FarmerThiever,ManThiever, and the bankstander scripts. Poor candidates:DoublingMoney(a scammer NPC),Idler, and world-immersion combat bots likeGreenDragonKiller.
The reverse (making a player script also spawn as world AI) means giving it a
newInstance()/spawn that builds an AIPlayer via an assembler and adding a
GeneralBotCreator(script, location) call in ImmerseWorld.
The ScriptAPI toolbox
core.game.bots.ScriptAPI (constructed as scriptAPI on your Script) is the automation
surface. Grouped highlights:
Finding things
getNearestNode(name)/getNearestNode(id, isObject)/getNearestNodeFromList(names, isObject)getNearestGameObject(loc, objectId),getNearestObjectByPredicate { ... }distance(n1, n2)
Movement
walkTo(loc),walkArray(steps),randomWalkTo(loc, radius)randomizeLocationInRanges(loc, xMin, xMax, yMin, yMax, z)teleport(loc),teleportToGE()
Interacting / combat
interact(bot, node, option),useWith(bot, itemId, node)attackNpcInRadius(bot, name, radius),attackNpcsInRadius(bot, radius)takeNearestGroundItem(id)
Items / banking / Grand Exchange
bankItem(id),bankAll { onComplete },depositAtBank(),withdraw(id, amount)sellOnGE(id),sellAllOnGe(),sellAllOnGeAdv(),buyFromGE(bot, id, amount)eat(foodId),forceEat(foodId)equipAndSetStats(item(s)),loadAppearanceAndEquipment(json)
Feedback / UI
sendChat(message)getOverlay(): BottingOverlay→init(),setTitle(...),setTaskLabel(...),setAmount(n)— the little progress panel scripts show while running.
How to add a new self-bot script (checklist)
- Create
Server/src/main/content/global/bots/<YourScript>.ktextendingScript(). - Annotate it with
@PlayerCompatible,@ScriptName,@ScriptDescription,@ScriptIdentifier("unique_id"). - Implement
tick()as a state machine drivingscriptAPIcalls; add aBottingOverlayfor progress feedback if useful. - Implement
newInstance()returningthis. - Build and run with
enable_botting = trueindefault.conf; test in-game with::scriptsthen::script unique_id, and::stopscriptto end. - Remember
inventory/equipment/skills/questsfields are ignored when a real player runs it — don't rely on them for player self-botting; instead work with whatever the player already has (bank/withdraw/buy as needed).
Key files at a glance
| File | Role |
|---|---|
core/game/system/command/sets/BottingCommandSet.kt |
The ::scripts / ::script / ::stopscript commands |
core/game/bots/Script.java |
Base class every script extends |
core/game/bots/ScriptAPI.kt |
Automation helper API (scriptAPI) |
core/game/bots/GeneralBotCreator.kt |
Wraps a script in a per-tick BotScriptPulse |
core/game/bots/PlayerScripts.kt |
Registry (identifierMap) of player-runnable scripts |
core/game/bots/PlayerCompatible.kt + ScriptName/ScriptDescription/ScriptIdentifier |
The annotations ClassScanner reads |
core/plugin/ClassScanner.kt |
Discovers @PlayerCompatible scripts at startup |
content/global/bots/*.kt |
The actual scripts (add yours here) |
worldprops/default.conf → enable_botting |
Feature flag |