SDK Examples & Best Practices
This page documents the committed source. Treat revision-sensitive RuneLite UI, packet, or in-game outcomes as pending live-client verification unless the page records direct evidence.
This guide provides practical examples, code snippets, and proper vs. improper usage patterns for developers building or maintaining plugins using the com.n3plugins.sdk.* and com.n3plugins.Api.* layers.
1. Cheat Sheet: Proper vs. Improper Patterns
A. Action Pacing
ActionPacer is suite-wide and driven by PacketUtilsPlugin. Do not invoke the pacer checks directly in your tick loop when using action APIs.
Improper (Do NOT do this):
@Subscribe
public void onGameTick(GameTick event) {
// BUG: microsecond sync diff causes this to ALWAYS return false because PacketUtilsPlugin
// has already incremented the tick count and added ms jitter for the current tick.
if (!ActionPacer.isReady(System.currentTimeMillis())) return;
NPCActions.interact("Banker", "Bank");
}
Proper (Do this instead):
@Subscribe
public void onGameTick(GameTick event) {
// Trust NPCActions to check the pacer internally. Inspect the result to react.
InteractionResult result = NPCActions.interact("Banker", "Bank");
if (result.getStatus() == InteractionStatus.PACED) {
// Pacer is on cooldown. Re-evaluate next tick.
}
}
B. Spatial Queries
RuneLite’s entity lists are not sorted by distance. Using .first() to find the nearest object or NPC creates unstable bot behavior.
Improper (Do NOT do this):
// BUG: Grabs the first entity returned in raw memory order, not the nearest to the player.
TileObject tree = TileObjects.search().withName("Yew").first().orElse(null);
Proper (Do this instead):
// Correct: uses path-reachable calculations to select the nearest entity.
TileObject tree = TileObjects.search()
.withName("Yew")
.nearestByPath()
.orElse(null);
// Correct (For work areas): selects target closest to a stable landmark anchor.
TileObject workAreaTree = TileObjects.search()
.withName("Yew")
.walkable()
.nearestToPoint(workAreaAnchor)
.orElse(null);
C. Spell widget resolution
Spellbook widgets are revision-backed UI addresses. Do not add a second list of spell widget IDs in a feature plugin or depend on removed widget-constant wrappers.
Improper (Do NOT do this):
// A copied packed ID will drift and bypasses the authoritative spell catalog.
int highAlchemyWidget = copiedFromAnOldPlugin;
WidgetActions.interact(highAlchemyWidget, "Cast");
Proper (Do this instead):
Optional<Integer> resolved = MagicActions.resolveSpellInfo("High Level Alchemy");
if (!resolved.isPresent()) {
return; // Keep the workflow idle until the name/configuration is corrected.
}
// The resolver derives this packed ID from Spell.HIGH_LEVEL_ALCHEMY.
InteractionResult selected = MagicActions.cast(Spell.HIGH_LEVEL_ALCHEMY, target);
The resolver normalizes friendly names and preserves compatible suffix matching. Use resolveSpellWidget(...) only when the workflow needs the current live widget; it can be empty when the spell is not visible in the active spellbook.
D. Query Object Caching
Query builders scan and cache data per-tick. Caching a query builder instance across ticks causes the plugin to read stale state.
Improper (Do NOT do this):
public class StalePlugin extends Plugin {
private ItemQuery inventoryQuery; // BUG: Stores stale query state
@Subscribe
public void onGameTick(GameTick event) {
if (inventoryQuery == null) {
inventoryQuery = Inventory.search().withName("Lobster");
}
int foodCount = inventoryQuery.count(); // Reads count from the caching tick
}
}
Proper (Do this instead):
public class FreshPlugin extends Plugin {
@Subscribe
public void onGameTick(GameTick event) {
// Correct: Query fresh scene data on every tick
int foodCount = Inventory.search().withName("Lobster").count();
}
}
E. Dialogue Handlers
OSRS dialogue widgets are stateless. The server creates and destroys them dynamically. Do not construct multi-tick lockout state-machines or backoffs for dialogue.
Improper (Do NOT do this):
// BUG: Over-engineered stateful backoff that can easily get stuck or drop clicks
if (Dialogue.isPresent()) {
if (clickRetries++ < 3) {
DialogActions.continueDialogue();
} else {
waitTicks = 5;
clickRetries = 0;
}
}
Proper (Do this instead):
// Correct: Stateless, single-tick reaction. Click if visible, otherwise do nothing.
private int lastFiredTick = -1;
@Subscribe
public void onGameTick(GameTick event) {
int currentTick = client.getTickCount();
if (currentTick == lastFiredTick) return; // Prevent double-clicking in the same tick
if (Dialogue.isPresent()) {
DialogActions.continueDialogue();
lastFiredTick = currentTick;
}
}
F. Static Mocking in Unit Tests
The testing framework restricts the use of MockedStatic or mockito-inline. Use injected interfaces, fake adapter mappings, or testing seams instead.
Improper (Do NOT do this):
@Test
public void testBankOpen() {
// BUG: mockStatic violates the project's testing constraints and will fail compiling
try (MockedStatic<Bank> mocked = mockStatic(Bank.class)) {
mocked.when(Bank::isOpen).thenReturn(true);
assertTrue(myController.isReady());
}
}
Proper (Do this instead):
@Before
public void setUp() {
// Correct: Inject a mock Client directly into the SDK testing hook
mockClient = mock(Client.class);
Widgets.setClientForTesting(mockClient);
ActionPacer.reset(); // Reset singleton pacing state
}
@After
public void tearDown() {
Widgets.resetClientForTesting();
}
G. Ordered game-tick decisions
Use TickDecisionList instead of duplicating a lastFiredTick guard when several combat policies must run in a fixed order. Call onTick(client.getTickCount()) from the plugin's onGameTick handler. Each leaf must return without sleeping and must dispatch no more than one meaningful action.
private final TickDecisionList decisions = TickDecisionList.of(
new PotionDecision().addPotion(prayerPotion, this::needsPrayer),
new EatDecision(client),
attackDecision);
@Subscribe
public void onGameTick(GameTick event) {
decisions.onTick(client.getTickCount());
}
Return true from a leaf when it reserves the tick, such as an accepted attack. Return false when later compatible decisions may run. Do not call Thread.sleep, Sleep.sleep, or event waits from a decision. See tick-decisions.md for food maps, combo behavior, potion variants, and test guidance.
SDK Snapshot Helpers
The query builders keep their legacy result() terminals and expose immutable results() snapshots for code that wants common terminals without mutating the builder further:
QueryResults<TileObject> trees = TileObjects.search()
.withName("Yew")
.walkable()
.results();
TileObject nearestByAnchor = trees.nearestTo(workAreaAnchor).orElse(null);
QueryResults.nearestTo(...) and sortedByDistanceTo(...) use straight-line tile distance to the supplied anchor. Use existing path-aware query methods such as nearestByPath() when reachability decides the action target.
Read-only gap-fill APIs live under sdk.items, sdk.world, sdk.menu, sdk.social, sdk.widgets, sdk.client, sdk.progress, and sdk.events. Current examples include item metadata/prices, bank state, bank worn-equipment reads, production reads, minigame/grouping reads, world/world-map reads, menu snapshots, social snapshots, camera reads, line-of-sight helpers, quest/diary progress reads, and passive event snapshots. Gameplay writes remain under Api.actions.*. We limit bank worn-equipment write coverage to the stable deposit-all-worn-equipment action until we validate per-slot widget actions in-client.
Use the read surfaces to choose or explain an action, then call the matching result-aware action:
Production.findProduct("Shortbow")
.ifPresent(product -> log.debug("Make-X option {}", product.getIndex()));
InteractionResult result = ProductionActions.chooseOption("Shortbow");
Quest and diary progress reads mirror requirement helpers without adding gameplay writes:
InteractionResult cooksDone = QuestProgressApi.isComplete(Quest.COOKS_ASSISTANT);
DiaryProgressApi.progress(DiaryRegion.LUMBRIDGE, DiaryTier.EASY)
.filter(DiaryProgress::isComplete)
.ifPresent(progress -> log.debug("Lumbridge easy complete"));
DiaryProgressApi exposes only explicitly mapped varbit/varplayer-backed tasks; unknown diary coverage is absent rather than guessed.
Passive event snapshots are bounded recent reads registered by PacketUtils:
SdkEvents.xpGained().forEach(event ->
log.debug("{} gained {} xp", event.getSkill(), event.getGainedXp()));
SdkEvents.npcSpawns().stream()
.filter(event -> "Banker".equals(event.getName()))
.findFirst();
Use AutomationLoop when a plugin needs simple loop-style ergonomics while keeping ownership in its own onGameTick:
AutomationLoop loop = new AutomationLoop(
AutomationLoopConfig.builder(this)
.tickSupplier(client::getTickCount)
.requireBootstrap(true)
.breakGate(() -> breakHandler.shouldBreak(this),
() -> breakHandler.isBreakActive(this),
() -> breakHandler.startBreak(this))
.build(),
context -> {
InteractionResult result = NPCActions.interact("Banker", "Bank");
return result.succeeded()
? LoopPulseResult.active(result.getMessage())
: LoopPulseResult.waiting(result.getMessage());
});
@Subscribe
public void onGameTick(GameTick event) {
loop.pulse();
}
AutomationLoop.fromPipeline(...) and AutomationLoop.fromStateMachine(...) adapt existing n3 workflows for loop plugins without creating another pacer, walker, or global tick owner.
For paced dropping, use InventoryDropActions rather than a loop of raw widget clicks:
InteractionResult result = InventoryDropActions.dropNext(
InventoryDropPattern.rowMajor(),
widget -> widget.getItemId() == ItemID.WILLOW_LOGS);
3. Grand Exchange Buying Pipeline
The former inline GE buying pipeline exists as the registered com.n3plugins.gebuyer.GeBuyerPlugin automation plugin. See docs/ge-buyer.md` and the source package for the production version.
The plugin intentionally runs one setup pipeline at once. It owns an editable queue, opens the Grand Exchange when needed, ticks a single GrandExchangeActions.createBuyPipeline(itemId, quantity, priceEach) placement flow, and monitors submitted buy slots separately.
Controller diagnostics use TaskPipeline.lastResult(), currentStepName(), currentStepTicks(), and currentStepAttempts() to detect stalls. Completed orders record through com.n3plugins.sdk.market.BuyLimitTracker. It calls GrandExchangeActions.collectAll() no earlier than the tick after a fill is first observed.
GE Buyer spends coins. Check the queue window's item ID, quantity, price, and buy-limit values before enabling automation.
4. Stateless Quest Dialogue Helper
This snippet illustrates how to write a stateless dialogue helper that interacts with Quest Helper marked options (e.g. [N] prefixes) using the optimal per-tick self-limiting pattern.
package com.n3plugins.dialogue;
import com.n3plugins.Api.actions.DialogActions;
import com.n3plugins.sdk.query.Dialogue;
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 QuestDialoguePlugin extends Plugin {
@Inject private Client client;
private int lastFiredTick = -1;
@Subscribe
public void onGameTick(GameTick event) {
int currentTick = client.getTickCount();
if (currentTick == lastFiredTick) return; // Self-limit: 1 dialog click per tick max
// Dialogue Helper does not use ActionPacer - it's a one-shot per tick check
if (Dialogue.isPresent()) {
// 1. Priority: check if there is an active Quest Helper option (prefixed with [N])
InteractionResult resolvedOption = DialogActions.chooseQuestHelperMarkedOption().succeeded();
if (resolvedOption) {
lastFiredTick = currentTick;
return;
}
// 2. Fallback: Click space/continue if simple dialogue text is open
if (Dialogue.isPresent()) {
DialogActions.continueDialogue();
lastFiredTick = currentTick;
}
}
}
}