Skip to main content

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.

Caution

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");
}
tip

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.

Caution

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);
tip

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.

Caution

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
}
}
tip

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.

Caution

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;
}
}
tip

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.

Caution

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());
}
}
tip

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 selection
  • ObjectActions.interact(..., "Chop down") for chopping
  • UseItemActions.widgetOnWidget(knife, log) only to open production
  • ProductionWorkflowBuilder backed by ProductionActions and ProductionApi for Make-X
  • BankWorkflowBuilder.reconcile(...) for restocking
  • active-only Break Handler tracking and PacketUtils bootstrap gating while running
Caution

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.

Caution

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;
}
}
}
}