Developer docs
Threading and performance
Which thread runs what
| Thread | Runs | Allowed |
|---|---|---|
| Your script's executor | The loop scheduled in run(), guarded steps, recovery handlers | Everything: clicking, walking, waiting |
| Client thread | Overlays (render, build), @Subscribe event handlers, clientThread.invoke(...) | Read game state and return. Never click, sleep or wait |
| Swing UI thread | Plugin startUp() and shutDown(), the config panel | Start or stop the loop and return |
Never click on the client thread
A click made on the client thread is only queued, and the call returns at once. Whatever comes next, a sleepUntil or a check that the action worked, runs before the click has happened, so the action looks like it failed. Find things on the client thread; click from the script thread.
// Right: search and click from the script thread. The query hops to the client thread for you.
var rock = Objects.query("Iron rocks").nearest();
if (rock != null && rock.interact("Mine")) {
sleepUntil(Player::isAnimating, 2_000);
}
// Wrong: clicking from inside a client-thread call.
ProjectX.getClientThread().runOnClientThreadOptional(() -> {
Widget w = ProjectX.getClient().getWidget(InterfaceID.Bankmain.UNIVERSE);
return Widgets.clickWidget(w); // queued, not done
});The same goes for waiting: sleep, sleepUntil and sleepTicks return immediately on the client thread without waiting at all, and guarded steps refuse to run there.
Reading game state
The helpers (Inventory, Player, Bank and the rest) hop to the client thread for you, so Inventory.isFull() or Player.isMoving() are safe anywhere. When you read the RuneLite client directly, do it on the client thread:
int energy = ProjectX.getClientThread()
.runOnClientThreadOptional(() -> ProjectX.getClient().getEnergy())
.orElse(0);runOnClientThreadOptional waits up to 10 seconds for the client thread and returns an empty Optional on timeout or error, so give orElse a safe default.
ProjectX.getClientThread().invoke(() -> someBoolean()). A lambda returning a boolean picks the overload that means “run this on the client thread, and run it again every tick until it returns true”. It returns nothing, so boolean b = clientThread.invoke(...) does not compile, and as a plain statement it quietly re-runs until the condition comes true. Use runOnClientThreadOptional to read a value.Overlays and event handlers
- They run on the client thread, every frame or every event. Read fields your script keeps up to date and return.
- Mark fields shared between the loop and the overlay
volatile, or use thejava.util.concurrent.atomictypes for counters. - No network calls:
GrandExchange.getPriceand the other price lookups make HTTP requests. Fetch prices in the loop and keep the result in a field. - An event handler that needs to act (bank, walk, click) should set a flag or put work in a queue for the loop to pick up.
Performance
- One loop at one tick (600 ms) is plenty. A faster loop just repeats work the game has not caught up with.
- Wait on conditions, not clocks:
sleepUntil(() -> condition, timeoutMs)returns as soon as the condition holds. - Query the caches with filters (
nameEquals,within) before customwherepredicates, and never keep an entity from one pass to the next: query again. - Build the stats panel from fields. Do not search the scene from
build(). - Catch exceptions inside the loop. One that escapes cancels the schedule, and the script sits there doing nothing.
- Restore the interrupt flag if you catch
InterruptedException, and return: it means the script is being switched off.