Files
2009scape/docs/BOT_SCRIPTING.md
2026-07-05 18:14:54 -04:00

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:

  1. AI players (AIPlayer) — fake, server-spawned characters used to populate the world (PvMBots, CombatBot, the various assemblers/builders). They are not real accounts.
  2. Player self-botting — a real logged-in Player runs a Script on their own character via the ::script command. 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 attribute botting:warning_shown) before the list appears.
  • ::script <identifier> — looks up the script in PlayerScripts.identifierMap, closes interfaces, and starts it on the calling player via GeneralBotCreator(script.newInstance() as Script, player, isPlayer = true). Also shows the highscores warning on first use.
  • ::stopscript — retrieves the running pulse from the player's botting:script attribute 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_bottingServerConfigParserGameSettings.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 in init).
  • 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); return this.

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():

  1. If a random delay is pending, decrements it and skips (simulates human hesitation).
  2. If the bot is stuck mid-MovementPulse for >5 ticks, cancels the movement.
  3. 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. (Set endDialogue = false in your script if you need dialogue to persist — e.g. boat travel.)
  4. 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 for Idler).

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 by core.game.world.ImmerseWorld (a StartupListener, gated by enable_bots / max_adv_bots config) via GeneralBotCreator(script, location) — the bot is a fabricated AIPlayer whose gear, stats, and inventory are injected by SkillingBotAssembler / CombatBotAssembler. These are the ones without @PlayerCompatible (e.g. CowKiller, ManThiever, FarmerThiever, VarrockSmither, GreenDragonKiller, DraynorFisher, Idler, DoublingMoney).
  • Player self-bots — launched by ::script via GeneralBotCreator(script, player, isPlayer = true). The bot is the real player, and Script.init() skips the inventory/equipment/skills/quests injection (that only runs when isPlayer == false).

Converting an AI-world bot into a player self-bot is usually straightforward because the core logic is identical. Steps:

  1. Add the four registration annotations (@PlayerCompatible, @ScriptName, @ScriptDescription, @ScriptIdentifier). This alone lists it in ::scripts.
  2. 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.
  3. Drop fixed spawn-location assumptions — a player runs from where they stand. The newInstance() overrides that build a fresh AIPlayer at a spawn zone are irrelevant in player mode (and that respawn path is dead code); just return this.
  4. 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 like GreenDragonKiller.

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(): BottingOverlayinit(), setTitle(...), setTaskLabel(...), setAmount(n) — the little progress panel scripts show while running.

How to add a new self-bot script (checklist)

  1. Create Server/src/main/content/global/bots/<YourScript>.kt extending Script().
  2. Annotate it with @PlayerCompatible, @ScriptName, @ScriptDescription, @ScriptIdentifier("unique_id").
  3. Implement tick() as a state machine driving scriptAPI calls; add a BottingOverlay for progress feedback if useful.
  4. Implement newInstance() returning this.
  5. Build and run with enable_botting = true in default.conf; test in-game with ::scripts then ::script unique_id, and ::stopscript to end.
  6. Remember inventory/equipment/skills/quests fields 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.confenable_botting Feature flag