Skip to content
Project X

Developer docs

Script lifecycle

A script is two classes working together: a Plugin that the client switches on and off, and a Script that runs the loop on its own thread. This page follows one run from start to stop.

From switch-on to switch-off

  1. The player switches the plugin on. The client calls your plugin's startUp() on the Swing UI thread.
  2. startUp() adds the overlay and calls script.run(config), which schedules the loop and returns straight away.
  3. The loop runs on the script's own background thread, once per game tick, until it is cancelled.
  4. The player switches the plugin off. The client calls shutDown(), which calls script.shutdown() and removes the overlay.

The plugin

@PluginDescriptor(
        name = "My Cool Script",
        description = "One line about what it does",
        tags = {"woodcutting", "skilling"},
        version = MyCoolPlugin.version,
        minClientVersion = "2.7.0",    // the plugin API level you need, see Versioning
        enabledByDefault = false,
        isExternal = true             // required for anything not built into the client
)
public class MyCoolPlugin extends Plugin {
    public static final String version = "1.0.0";

    @Inject private MyCoolScript script;
    @Inject private MyCoolConfig config;
    @Inject private OverlayManager overlayManager;
    @Inject private MyCoolOverlay overlay;

    @Provides
    MyCoolConfig provideConfig(ConfigManager configManager) {
        return configManager.getConfig(MyCoolConfig.class);
    }

    @Override
    protected void startUp() {
        overlayManager.add(overlay);
        script.run(config);          // schedule the loop and return
    }

    @Override
    protected void shutDown() {
        script.shutdown();
        overlayManager.remove(overlay);
    }
}

The plugin class's simple name is the script's identity. The jar is named after it, and your store listing's internal name must match it exactly. Do not rename it after publishing.

The Script base class

Extend net.runelite.client.plugins.projectx.Script. It gives you:

MemberWhat it is for
scheduledExecutorServiceThe thread pool your loop runs on. Never the client thread.
mainScheduledFutureAssign your loop's schedule to this. shutdown() cancels it, and isRunning() checks it.
super.run()The pre-loop check. Call it first in every pass and do nothing when it returns false.
reliability()Guarded steps, recovery and safe stops. See Reliability.
shutdown()Cancels the loop and resets the shared helpers. Override it to clear your own state, and call super.shutdown() first.
isRunning()True while the main loop is scheduled. Handy in the overlay.

Script also inherits the waiting helpers: sleepUntil(condition, timeoutMs), sleep(min, max), sleepGaussian(mean, stddev), sleepUntilNextTick() and sleepTicks(ticks). All of them return at once, without waiting, if called on the client thread.

super.run() returns false while a blocking event is being handled (the login screen, a level-up message, a random event), while scripts are paused, and once the thread has been interrupted. It also turns run on and drinks stamina potions according to the player's Project X settings.

The loop

public class MyCoolScript extends Script {
    private volatile long startedAt;

    public boolean run(MyCoolConfig config) {
        startedAt = System.currentTimeMillis();

        mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> {
            try {
                if (!super.run() || !ProjectX.isLoggedIn()) return;

                // One state's worth of work, then return. The next pass carries on.
                if (Inventory.isFull()) {
                    bank();
                } else {
                    work(config);
                }
            } catch (Exception e) {
                // Anything that escapes cancels the schedule silently.
                log.error("MyCool loop failed", e);
            }
        }, 0, 600, TimeUnit.MILLISECONDS);   // 600 ms is one game tick
        return true;
    }

    @Override
    public void shutdown() {
        super.shutdown();
        // clear your own fields here, so the next start is clean
    }
}

Keep each pass short and decide what to do from the game, not from what the last pass assumed. A script that asks “is the inventory full? is the bank open? am I at the spot?” every pass can be started anywhere and recovers from anything. For scripts with many phases, a small enum of states and one method per state keeps this readable; the template shows the pattern. The client also has an opt-in StateMachineScript<S> base class with declared transitions, if you prefer that style.

Stopping cleanly

When the script cannot carry on (out of supplies, goal reached, a setting missing), stop it and tell the player why:

if (!Bank.hasItem("Shark", true)) {
    reliability().stop("Out of sharks in the bank.");   // shows the message, then calls shutdown()
    return;
}

reliability().stop(reason) works whether or not the player has reliability switched on. To do it by hand, call ProjectX.showMessage(reason) and then shutdown(), and return from the loop straight away: shutdown() interrupts the thread you are on.

ProjectX.showMessage(String) opens a dialog and returns immediately; it never waits for the player to click. ProjectX.showMessage(String, int disposeTimeMs) closes itself after that long. Empty messages are ignored.

Status text

ProjectX.status is a single shared string, and many helpers write to it themselves: opening the bank sets it to “Opening bank”, clicking an object sets it to the action and the name. For the status line in your stats panel, keep a volatile String status field of your own and show that.