Writing a Plugin
Plugins extend dev.twilite.client.plugins.Plugin and are discovered through @PluginDescriptor.
The plugin lifecycle is intentionally small:
configure(PluginContext)is called by the client before startup.startUp()is called when the plugin is enabled.shutDown()is called when the plugin is disabled.@Subscribemethods receive events from the shared event bus.
Basic Plugin
package com.example.plugins;
import dev.twilite.client.eventbus.Subscribe;
import dev.twilite.client.events.ServerTickEvent;
import dev.twilite.client.plugins.Plugin;
import dev.twilite.client.plugins.PluginDescriptor;
@PluginDescriptor(
name = "Example",
description = "A small example plugin.",
tags = {"example", "docs"}
)
public class ExamplePlugin extends Plugin {
@Override
protected void startUp() {
log.info("Example started.");
}
@Override
protected void shutDown() {
log.info("Example stopped.");
}
@Subscribe
private void onServerTick(ServerTickEvent event) {
log.info("Server tick.");
}
}
Descriptor Fields
@PluginDescriptor(
name = "Example",
configName = "example",
description = "Shown in the plugin list.",
tags = {"combat", "utility"},
enabledByDefault = false,
config = ExampleConfig.class,
overlay = ExampleOverlay.class
)
Use configName when you want a stable storage namespace that is different from the display name.
Use enabledByDefault = false for plugins that should not start automatically for new users.
Dependency Injection
Plugins can use constructor injection. The client creates one managed instance, so injected services and config objects are the same instances used elsewhere.
import com.google.inject.Inject;
import dev.twilite.client.plugins.Plugin;
import dev.twilite.client.plugins.PluginDescriptor;
@PluginDescriptor(
name = "Example",
config = ExampleConfig.class
)
public class ExamplePlugin extends Plugin {
private final ExampleConfig config;
@Inject
public ExamplePlugin(ExampleConfig config) {
this.config = config;
}
@Override
protected void startUp() {
log.info("Enabled with radius {}", config.radius());
}
}
Context Helpers
Plugin exposes protected helpers for common client services:
eventBus();
configManager();
pluginController();
overlayManager();
Plugin State
Keep plugin state inside the plugin instance or an injected service. Reset temporary state in shutDown() so disabling and re-enabling the plugin starts cleanly.
private int ticks;
@Subscribe
private void onServerTick(ServerTickEvent event) {
ticks++;
}
@Override
protected void shutDown() {
ticks = 0;
}
TickPlugin
Use TickPlugin when the plugin is mostly one loop that runs on server ticks. Implement tick() and return a TickResult to control when it runs again.
package com.example.plugins;
import dev.twilite.client.plugins.PluginDescriptor;
import dev.twilite.client.plugins.TickPlugin;
import dev.twilite.client.plugins.TickResult;
import dev.twilite.game.Inventory;
import dev.twilite.game.id.ItemId;
@PluginDescriptor(
name = "Example Tick Plugin",
description = "Runs once per server tick."
)
public class ExampleTickPlugin extends TickPlugin {
@Override
protected TickResult tick() {
Inventory.item(ItemId.SHARK).ifPresent(item -> item.interact("Eat"));
return TickResult.wait(2);
}
}
Common results:
TickResult.next()runs again on the next server tick.TickResult.wait(ticks)waits for a fixed number of server ticks.TickResult.waitUntil(condition, timeout)waits until a condition is true or the timeout expires.
TickPlugin is useful for scripts that are naturally a small state machine, such as checking the current stage and doing one action.
BehaviorPlugin
Use BehaviorPlugin when the plugin is easier to describe as a behavior tree. The tree returns SUCCESS, FAILURE, or RUNNING through BehaviorResult.
package com.example.plugins;
import dev.twilite.client.plugins.Behavior;
import dev.twilite.client.plugins.BehaviorPlugin;
import dev.twilite.client.plugins.BehaviorResult;
import dev.twilite.client.plugins.PluginDescriptor;
import dev.twilite.game.Inventory;
import dev.twilite.game.id.ItemId;
import static dev.twilite.client.plugins.Behavior.*;
@PluginDescriptor(
name = "Example Behavior Plugin",
description = "Uses a behavior tree."
)
public class ExampleBehaviorPlugin extends BehaviorPlugin {
@Override
protected Behavior behavior() {
return selector(
"Root",
sequence(
"Eat",
condition(this::shouldEat),
action("Eat food", this::eat)
),
action("Idle", () -> BehaviorResult.running())
);
}
private boolean shouldEat() {
return Inventory.item(ItemId.SHARK).isPresent();
}
private BehaviorResult eat() {
Inventory.item(ItemId.SHARK).ifPresent(item -> item.interact("Eat"));
return BehaviorResult.wait(2);
}
}
Use selector(...) for fallback behavior: it tries children until one succeeds or keeps running. Use sequence(...) for ordered behavior: it runs children until one fails or keeps running.
Common results:
BehaviorResult.success()means this node completed.BehaviorResult.failure()means this node does not apply, so a selector can try the next branch.BehaviorResult.running()means this branch is still active.BehaviorResult.wait(ticks)keeps this branch active while waiting.BehaviorResult.stopPlugin()disables the plugin.
Larger Behavior Tree
For larger plugins, keep one root tree and split each concern into a smaller behavior. Put urgent or high-priority branches first.
@Override
protected Behavior behavior() {
return selector(
"Root",
emergency(),
dialog(),
banking(),
combat(),
travel(),
action("Idle", () -> BehaviorResult.running())
);
}
private Behavior emergency() {
return sequence(
"Emergency",
condition(this::lowHealth),
action("Eat", this::eat)
);
}
private Behavior dialog() {
return action("Continue dialog", () ->
Dialog.select() ? BehaviorResult.wait(1) : BehaviorResult.failure()
);
}
private Behavior banking() {
return sequence(
"Banking",
condition(this::needsBank),
selector(
"Reach bank",
sequence(
condition(this::bankVisible),
action("Open bank", this::openBank)
),
action("Walk to bank", this::walkToBank)
),
action("Deposit loot", this::depositLoot),
action("Withdraw supplies", this::withdrawSupplies)
);
}
private Behavior combat() {
return sequence(
"Combat",
condition(this::readyToFight),
selector(
"Target",
action("Attack current target", this::attackCurrentTarget),
action("Find target", this::findTarget)
)
);
}
private Behavior travel() {
return sequence(
"Travel",
condition(this::shouldTravel),
action("Walk", this::walk)
);
}
Example action methods:
private BehaviorResult eat() {
Inventory.item(ItemId.SHARK).ifPresent(item -> item.interact("Eat"));
return BehaviorResult.wait(2);
}
private BehaviorResult walkToBank() {
Navigation.step(bankTile);
return BehaviorResult.running();
}
private BehaviorResult openBank() {
Game.rootWorld()
.flatMap(world -> world.objects().named("Bank booth").nearestTo(bankTile))
.ifPresent(booth -> booth.interact("Bank"));
return BehaviorResult.waitUntil(Bank::open, 5);
}
This keeps the plugin readable: the root shows priority, each section owns one concern, and each action decides whether to succeed, fail, keep running, or wait.
Build Output
Plugin jars should not bundle TwiLite API classes. Use Maven provided or Gradle compileOnly for dev.twilite:twilite-bot:latest.