Beginner Scripting Guide & Thinking Patterns
Use this guide to learn the event-driven shape of n3Plugins automation. The examples are illustrative plugin fragments: they show the decision loop and real API signatures, but omit registration, configuration, Break Handler lifecycle, and shutdown cleanup required by a production plugin.
1. The Core Concepts You Must Grasp
A. The 600ms Server Tick
RuneScape advances game state on server ticks that are approximately 600 ms.
- Your code runs on a tick timer: RuneLite invokes
@Subscribe public void onGameTick(GameTick event)once every 600ms. - Advance one decision at a time: Do not assume several dependent actions (withdraw, equip, close bank) can complete in one callback. Dispatch one action, return, and observe the resulting state on a later tick.
- React, don't sleep: Never use
Thread.sleep()or busy-waiting loops in your scripts. If you need to wait, return from the tick handler and let the next tick evaluate the new state.
B. Stateless vs. Stateful Thinking
Beginners often write "stateful" scripts: they click a tree, set a variable isChapping = true, and wait. If the player gets attacked, walks away, or logs out, the script is stuck because the code’s state does not match the game’s reality.
- Prefer observed state: Query inventory, widgets, actors, and objects each tick instead of assuming the previous click succeeded.
- Store workflow state only when needed: Pipelines and state machines need durable state, but that state must be reconciled with the live game before every action.
C. Runtime Gates And Ownership
The required gates depend on the plugin's role:
- Account bootstrap: Active automation that needs account setup calls
PacketUtilsPlugin.requireAccountBootstrap(owner)and releases that owner when idle or stopped. Passive overlays and bookkeeping do not need the gate. - Break Handler: Full automation plugins use active-only Break Handler tracking and pause actions during planned or active breaks. Helper plugins follow the explicit exemption policy in the SOT.
- Action pacing: Mutating
InteractionApi.actions.*methods apply shared pacing where their contract requires it and can returnPACED. The plugin handles that result by waiting and re-evaluating state.
2. Thinking Patterns: How to Design a Script
When designing a new script, follow this structured mental process:
Proximity: Path Reachability vs. Euclidean Distance
When looking for objects, the closest tree on your screen might be on the other side of a solid stone wall.
- Improper thinking: "Get the closest tree to my player's coordinates." (Using
nearestToPlayer()). - Optimal thinking: "Get the closest tree that I can actually walk to without hitches." (Using
nearestByPath()).
3. Beginner Walkthrough 1: My First Bone Buryer
This walkthrough demonstrates a simple skiller loop that finds bones in the inventory, buries them one-by-one, and pauses between ticks.
Step 1: The Loop Logic
- Register and verify account bootstrap while the loop is active.
- Search inventory for "Bones".
- If bones exist, bury one.
- Wait for the animation to finish before burying the next.
Step 2: The Implementation
package com.n3plugins.boneburyer;
import com.n3plugins.PacketUtils.PacketUtilsPlugin;
import com.n3plugins.InteractionApi.actions.InventoryActions;
import com.n3plugins.InteractionApi.common.InteractionResult;
import com.n3plugins.sdk.query.Inventory;
import net.runelite.api.Client;
import net.runelite.api.events.GameTick;
import net.runelite.api.widgets.Widget;
import net.runelite.client.eventbus.Subscribe;
import net.runelite.client.plugins.Plugin;
import javax.inject.Inject;
public class BoneBuryerPlugin extends Plugin {
@Inject private Client client;
private int lastFiredTick = -1;
@Subscribe
public void onGameTick(GameTick event) {
// 1. Deduplicate ticks (run once per 600ms)
int currentTick = client.getTickCount();
if (currentTick == lastFiredTick) return;
// 2. Register this active owner and wait for account setup.
if (!PacketUtilsPlugin.requireAccountBootstrap(this)) return;
// 3. Animation Guard: don't click if we are already burying a bone
if (client.getLocalPlayer().getAnimation() != -1) {
return;
}
// 4. Query the inventory for bones
Widget bone = Inventory.search()
.withName("Bones")
.first()
.orElse(null);
if (bone != null) {
// 5. Interact (bury the bone)
InteractionResult res = InventoryActions.use(bone, "Bury");
// If the action went through, lock this tick
if (res.succeeded()) {
lastFiredTick = currentTick;
}
}
}
}
4. Beginner Walkthrough 2: Linear Task Pipeline (The Bank Runner)
When you need to execute a set sequence of actions (e.g. open bank -> deposit items -> withdraw supplies -> close bank), a TaskPipeline is the optimal tool. It manages the steps without cluttering your code with state flags.
Step 1: Design the Steps
open-bank: Click the nearest bank booth or NPC.wait-bank: Wait until the bank interface is open.deposit: Deposit gathered logs.withdraw: Withdraw a fresh supply of coins.close-bank: Close the interface.
Step 2: The Implementation
package com.n3plugins.bankrunner;
import com.n3plugins.PacketUtils.PacketUtilsPlugin;
import com.n3plugins.InteractionApi.actions.BankActions;
import com.n3plugins.InteractionApi.plan.StepResult;
import com.n3plugins.InteractionApi.plan.StepStatus;
import com.n3plugins.InteractionApi.plan.TaskPipeline;
import com.n3plugins.sdk.query.Inventory;
import com.n3plugins.sdk.widgets.BankApi;
import net.runelite.api.Client;
import net.runelite.api.events.GameTick;
import net.runelite.client.eventbus.Subscribe;
import net.runelite.client.plugins.Plugin;
import javax.inject.Inject;
public class BankRunnerPlugin extends Plugin {
@Inject private Client client;
private TaskPipeline bankPipeline;
private int lastFiredTick = -1;
@Subscribe
public void onGameTick(GameTick event) {
int currentTick = client.getTickCount();
if (currentTick == lastFiredTick) return;
lastFiredTick = currentTick;
if (!PacketUtilsPlugin.requireAccountBootstrap(this)) return;
// Start banking if our inventory is full of Willow logs
boolean inventoryNeedsBanking = Inventory.search().withName("Willow logs").count() >= 27;
if (inventoryNeedsBanking && bankPipeline == null) {
bankPipeline = createBankingPipeline();
}
// Advance the pipeline
if (bankPipeline != null) {
StepResult result = bankPipeline.tick();
// If completed or failed, clear the pipeline
if (bankPipeline.complete() || result.getStatus() == StepStatus.FAILED) {
bankPipeline = null;
}
}
}
private TaskPipeline createBankingPipeline() {
return TaskPipeline.create()
// 1. Open the bank
.step("open-bank", () -> StepResult.fromInteraction(BankActions.openNearest()))
// 2. Wait 1 tick for the widget to appear
.step("wait-bank", () -> BankApi.isOpen()
? StepResult.success()
: StepResult.waitTicks(1, "Opening bank UI"))
// 3. Deposit the inventory (a production workflow would preserve
// required tools through its loadout/banking policy)
.step("deposit-inventory", () -> StepResult.fromInteraction(BankActions.depositAll()))
// 4. Close the bank
.step("close-bank", () -> StepResult.fromInteraction(BankActions.close()));
}
}
5. Scripting Invariants: The Golden Rules of n3Plugins
To pass code review, your scripts must conform to these architectural standards:
- Do not sleep: Always let the tick dispatcher (
onGameTick) drive script progression. - Stateless dialogues: Do not try to remember dialog clicks. If a dialogue widget is present, click it. If not, do nothing.
- Use an intentional spatial selector: Use
nearestByPath()for player-relative reachable targets, orwalkable().nearestToPoint(anchor)for a stable work area. Do not usefirst()as a nearest-target policy. - Own bootstrap lifecycle: Active automation that needs account setup registers an owner, gates gameplay on readiness, and releases the owner when idle or stopped.
- Return
InteractionResult: New mutating shared actions returnInteractionResult; plugin decision code inspects it instead of comparing messages.