SDK Examples & Best Practices
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.InteractionApi.* 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.
return;
}
}
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. Query Object Caching
Query builders scan and cache data per-tick. Caching a query builder instance across ticks will cause 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();
}
}
D. Dialogue Handlers
OSRS dialogue widgets are stateless. They are created and destroyed dynamically by the server. 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 (DialogueApi.isPresent()) {
if (clickRetries++ < 3) {
DialogueApi.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 (DialogueApi.isPresent()) {
DialogueApi.continueDialogue();
lastFiredTick = currentTick;
}
}
E. 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 or fake adapter directly into the SDK testing hooks
mockWidgetAdapter = mock(WidgetApi.Adapter.class);
WidgetApi.setAdapterForTesting(mockWidgetAdapter);
ActionPacer.reset(); // Reset singleton pacing state
}
@After
public void tearDown() {
WidgetApi.resetAdapterForTesting();
}
SDK Snapshot Helpers
The query builders keep their legacy result() terminals and also 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 now 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 InteractionApi.actions.*; bank worn-equipment write coverage is limited
to the stable deposit-all-worn-equipment action until per-slot widget actions
are validated in-client.
Use the read surfaces to choose or explain an action, then call the matching result-aware action:
ProductionApi.findProduct("Shortbow")
.ifPresent(product -> log.debug("Make-X option {}", product.getIndex()));
InteractionResult result = ProductionActions.chooseOption("Shortbow");
Quest and diary progress reads mirror competitor requirement helpers without adding gameplay writes:
boolean 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 DreamBot/Storm/Twilite-style 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);
2. Composite Example: Wood Fletcher Plugin
The former inline Woodcutter/Fletcher example now exists as the registered
com.n3plugins.woodfletcher.WoodFletcherPlugin automation plugin. See
docs/wood-fletcher.md and the source package for the production version.
The real plugin keeps the same teaching goals as the original example, but uses suite APIs instead of raw widget chains:
TileObjects.search().withMappedName(...).walkable().nearestByPath()for tree selectionObjectActions.interact(..., "Chop down")for choppingUseItemActions.widgetOnWidget(knife, log)only to open productionProductionWorkflowBuilderbacked byProductionActionsandProductionApifor Make-XBankWorkflowBuilder.reconcile(...)for restocking- active-only Break Handler tracking and PacketUtils bootstrap gating while running
The plugin can drop configured logs/products in drop mode. It never drops the knife or axe, but live-client verification is still required before relying on a new recipe unattended.
3. Grand Exchange Buying Pipeline
The former inline GE buying pipeline now exists as the registered
com.n3plugins.gebuyer.GeBuyerPlugin automation plugin. See docs/ge-buyer.md
and the source package for the production version.
The real plugin intentionally does not run multiple setup pipelines at once.
Instead, 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 are recorded through com.n3plugins.sdk.market.BuyLimitTracker, and
GrandExchangeActions.collectAll() is called no earlier than the tick after a
fill is first observed.
GE Buyer can spend 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.InteractionApi.actions.DialogActions;
import com.n3plugins.sdk.widgets.DialogueApi;
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 (DialogueApi.isPresent()) {
// 1. Priority: check if there is an active Quest Helper option (prefixed with [N])
boolean resolvedOption = DialogActions.chooseQuestHelperMarkedOption().succeeded();
if (resolvedOption) {
lastFiredTick = currentTick;
return;
}
// 2. Fallback: Click space/continue if simple dialogue text is open
if (DialogueApi.isPresent()) {
DialogueApi.continueDialogue();
lastFiredTick = currentTick;
}
}
}
}