Skip to content
Project X

Developer docs

Known traps

Each of these has broken a real script, usually without an error: the script just does the wrong thing, or nothing. They are worth reading once before your first release.

withdrawX by name needs a stack of at least that many

Bank.withdrawX(name, amount) and withdrawX(name, amount, exact) only match a bank stack holding at least amount. Ask for a full inventory's worth when the bank has less, and nothing is withdrawn and nothing is logged. Clamp the amount first:

int available = Bank.count("Yew logs", true);
if (available == 0) {
    reliability().stop("Out of yew logs.");
    return;
}
Bank.withdrawX("Yew logs", Math.min(27, available), true);

Name matching is not the same everywhere

Some methods match part of a name (case-insensitive “contains”), some match the whole name (case-insensitive “equals”), and the default differs between methods that look alike. Inventory.hasItem("axe") is true for a pickaxe; Inventory.contains("axe") is not.

Partial by defaultExact by default
Inventory.hasItem(String...)Inventory.contains(String...)
Inventory.count(String)Inventory.itemQuantity(String)
Inventory.interact(name, action), drop(String)Inventory.dropAll(String...)
Bank.hasItem(String), withdrawX(String, int), depositAll(String)queries: nameEquals(...), withNames(...)
Bank.depositAllExcept(String...)GrandExchange.buyItem(...)

Most of these have an overload with a boolean exact parameter. Pass true unless you really mean “any name containing this”, and use withNameContains on a query when you do.

Clicking on the client thread

A click made on the client thread (inside runOnClientThreadOptional, clientThread.invoke, an overlay or an event handler) is only queued, and the call returns before it happens. The wait after it is measured against the wrong moment, and the action looks like it failed, or worked when it did not. Find on the client thread, click from the script thread.

Widgets.clickWidget("text") clicks from inside a client-thread call itself. Use Widgets.findWidget(text, true) and then Widgets.clickWidget(widget) instead.

Ids baked in from an old client jar

ItemID, ObjectID, NpcID and InterfaceID values are static final constants, and the Java compiler copies their values into your classes. A jar built against an old client keeps the old numbers even when it runs on a new one. After a game update moves a widget, that script clicks the wrong thing until it is rebuilt.

Always build against the client players are running (projectxClientVersion on the latest release), and rebuild and upload after a client release that changes ids. For the same reason, never write raw widget numbers such as 786434: use the named ids, which move when you rebuild.

SkillTracker resets when startedAt changes

SkillTracker.observe(client, startedAt) treats a new startedAt as a new run and starts every number again from zero. Pass the epoch millis you stored once when the script started. Anything worked out per frame, such as System.currentTimeMillis() - runtime, changes every frame and resets the tracker every frame.

clientThread.invoke with a boolean lambda

ProjectX.getClientThread().invoke(() -> someBoolean()) picks the overload that runs the lambda on the client thread and runs it again every tick until it returns true. It returns nothing. Use runOnClientThreadOptional(() -> ...).orElse(false) to read a value.

count counts slots, not items

Inventory.count("Coins") is 1 for a stack of a million coins. Use Inventory.itemQuantity(name) for stackables: coins, runes, arrows, noted items, coin pouches.

Waiting does nothing on the client thread

sleep, sleepUntil and sleepTicks return immediately when called on the client thread, and sleepUntil returns false. Code that waits belongs in the loop.

An exception that escapes the loop stops it silently

scheduleWithFixedDelay cancels a task that throws. The plugin still looks switched on, the panel still draws, and nothing happens. Wrap the whole body of the loop in try / catch (Exception e) and log the error.

A query can be used once

A query is a stream underneath. After its first terminal call (nearest(), list(), first()), calling another on the same query throws. Build a fresh query each time, and query again rather than keeping an entity between passes.

Helpers overwrite ProjectX.status

Bank.openBank, depositAll, clicking an NPC or object, casting and the Grand Exchange helpers all set ProjectX.status themselves. Keep your own status field for your stats panel.

buyItem and sellItem take their arguments in different orders

GrandExchange.buyItem(name, price, quantity), but GrandExchange.sellItem(name, quantity, price). And hasBoughtOffer() is true when any slot has finished buying; use hasFinishedBuyingOffers() to wait for all of them.

Inventory setups by name fall back quietly

new Loadout("name", mainScheduledFuture) uses a setup called “default” when no setup has that name. Check Loadout.isInventorySetup(name) first, or use an InventorySetup config item. Passing a null InventorySetup to the constructor shows an error and pauses every running script, so check for null.

The game logs out an idle player

The game logs a player out after about five minutes without mouse or keyboard input, even mid-fight. A script that waits a long time without clicking should check Player.checkIdleLogout(marginInClientTicks) (client ticks are 20 ms) and make a harmless click before the limit.

Hopping worlds needs a clear screen

ProjectX.hopToWorld(world) refuses, and returns false, while the player is in combat or the bank, a shop or a dialogue is open. Close them first.