Skip to main content

Realtime WebSockets and Muling

TwiLite exposes a realtime WebSocket API for plugin coordination.

Messages are broadcast only to clients authenticated as the same TwiLite account.

Use this for coordination between many running clients, such as:

  • Workers asking for mule support.
  • Mule accounts offering to service requests.
  • A separate controller process coordinating clients without running the TwiLite game client.

Client API

Inject RealtimeClient when you need generic WebSocket messages.

import com.google.gson.JsonObject;
import com.google.inject.Inject;
import dev.twilite.client.eventbus.Subscribe;
import dev.twilite.client.plugins.Plugin;
import dev.twilite.client.plugins.PluginDescriptor;
import dev.twilite.client.realtime.RealtimeClient;
import dev.twilite.client.realtime.RealtimeMessage;
import dev.twilite.client.realtime.RealtimeScope;

@PluginDescriptor(name = "Realtime Example")
public class RealtimeExamplePlugin extends Plugin {

private final RealtimeClient realtime;

@Inject
public RealtimeExamplePlugin(RealtimeClient realtime) {
this.realtime = realtime;
}

@Override
protected void startUp() {
realtime.register(this);
}

@Override
protected void shutDown() {
realtime.unregister(this);
}

private void publishReady() {
JsonObject payload = new JsonObject();
payload.addProperty("kind", "READY");
payload.addProperty("player", "Example");

realtime.publish("example.ready", payload);
}

@Subscribe
@RealtimeScope("example.ready")
private void onRealtimeMessage(RealtimeMessage message) {
String player = message.payload().has("player")
? message.payload().get("player").getAsString()
: "";
log.info("Realtime ready message from {}", player);
}
}

RealtimeClient.register(this) scans for @Subscribe methods that take RealtimeMessage.

If a method does not specify @RealtimeScope, it subscribes to the default scope.

Scopes

A scope is a channel name. It keeps unrelated plugin messages separate.

Valid scope names may contain letters, numbers, :, ., _, and -, and must be no longer than 128 characters.

Examples:

default
mule.requests
mule.offers
example.ready
corp.team.assignment

Raw WebSocket Protocol

External tools can connect directly without running the TwiLite client.

Endpoint:

wss://twilite.dev/api/realtime/v1

Authentication:

Authorization: Bearer <license-api-token>

The license API token is the same token saved by the TwiLite client in %USERPROFILE%\twilite\secrets\client-token-production.properties.

Basic message flow:

{"id":"1","type":"HELLO","clientId":"controller-1","role":"controller","metadata":{"name":"Mule Controller"}}
{"id":"2","type":"SUBSCRIBE","scope":"mule.requests"}
{"id":"3","type":"PUBLISH","scope":"mule.requests","payload":{"kind":"REQUEST","requestId":"abc","requester":"worker1"}}

Server responses include:

  • WELCOME
  • SUBSCRIBED
  • UNSUBSCRIBED
  • PUBLISHED
  • MESSAGE
  • PONG
  • ERROR

Published messages are delivered as MESSAGE envelopes:

{
"type": "MESSAGE",
"scope": "mule.requests",
"correlationId": "3",
"from": {
"sessionId": "...",
"clientId": "controller-1",
"role": "controller"
},
"payload": {
"kind": "REQUEST",
"requestId": "abc",
"requester": "worker1"
}
}

Current server safeguards:

  • WebSocket handshakes require a valid license API token.
  • Handshakes are rate limited by remote address.
  • Message payloads are limited to 16KB.
  • Broadcasts are account-local.

Muling API

Most mule plugins should use MuleClient instead of publishing raw JSON.

MuleClient uses these scopes internally:

  • mule.requests
  • mule.offers
  • mule.assignments
  • mule.status

It publishes typed records and emits MuleEvent through the normal event bus.

High-Level Muling Helper

Worker plugins that only need to request a mule should use Muling.

Muling is a worker-side helper. It can:

  • Withdraw configured trade items before requesting a mule.
  • Queue missing items through a Restocker.
  • Broadcast mule requests.
  • Select a valid mule offer.
  • Hop to the assigned mule's world.
  • Walk to the mule.
  • Open trade, offer configured items, wait for coins, and accept.
  • Restore the original world after the trade.

The mule account should run the official Mule plugin with the same Mule ID.

Basic worker usage:

import com.google.inject.Inject;
import dev.twilite.client.plugins.PluginDescriptor;
import dev.twilite.client.plugins.TickPlugin;
import dev.twilite.client.plugins.TickResult;
import dev.twilite.client.realtime.mule.Muling;
import dev.twilite.game.facade.GrandExchange;
import dev.twilite.game.id.ItemId;
import dev.twilite.game.loadout.LoadoutItem;
import dev.twilite.game.loadout.Restocker;

@PluginDescriptor(name = "Muling Worker Example")
public class MulingWorkerExamplePlugin extends TickPlugin {

private final Muling muling;
private final Restocker restocker;

@Inject
public MulingWorkerExamplePlugin(Muling muling) {
this.muling = muling;
this.restocker = Restocker.create()
.defaultBuyPrice(GrandExchange.Price.live().plus5())
.defaultCollectionMode(GrandExchange.CollectionMode.BANK);

this.muling
.muleId("tob")
.restocker(restocker)
.offer(ItemId.SHARK, 100)
.offer(LoadoutItem.of(ItemId.DRAGON_BONES).amount(250).build());
}

@Override
protected TickResult tick() {
muling.requestIf(shouldMule(), 5_000_000L);

if (muling.tick()) {
return next();
}

if (muling.complete()) {
log.info("Muling completed.");
muling.reset();
} else if (muling.failed()) {
log.warn("Muling failed: {}", muling.failureReason());
muling.reset();
}

return next();
}

@Override
protected void shutDown() {
muling.close();
}

private boolean shouldMule() {
return false;
}
}

Important behavior:

  • requestIf(condition, coins) starts a new request only when the condition is true, no request is active, and the retry cooldown has elapsed.
  • coins is the amount the worker expects the mule to give during the trade.
  • tick() returns true while the plugin should stay in its muling branch.
  • offer(itemId, amount) automatically accepts noted and unnoted ids. For non-stackable items with a large amount, it withdraws noted items when possible.
  • bankBeforeRequest(true) is the default when offers are configured. The helper prepares offered items before broadcasting the request.
  • restocker(restocker) lets loadout depletion queue missing items into the supplied restocker.
  • muleId("...") is written into message metadata and must match the official Mule plugin's configured Mule ID.
  • Call close() when your plugin shuts down so the helper unregisters from the event bus.

Common configuration options:

muling
.muleId("tob")
.bankBeforeRequest(true)
.depositWornEquipment(false)
.restoreOriginalWorld(true)
.avoidInstances(true)
.maxRequestAttempts(3)
.maxTradeAttempts(6)
.muleMissingTimeoutMillis(30_000L)
.retryCooldownMillis(60_000L)
.acceptDelayMillis(1_200L)
.logoutOnTradeFailure(false);

Use MuleClient directly only when you need custom request matching, controller-driven assignment, or a custom mule implementation.

Mule Message Types

MuleRequest

Sent by a worker account that needs a mule.

Important fields:

  • requestId: unique request id generated by the worker.
  • requester: worker player name or account identifier.
  • world, x, y, floor: where service is needed.
  • coins: amount being muled.
  • members: whether the worker is on a members world.
  • expiresAt: epoch milliseconds after which the request should be ignored.
  • metadata: plugin-specific JSON. The official Mule plugin uses metadata.muleId to route requests.

MuleOffer

Sent by a mule account willing to service a request.

Important fields:

  • requestId: request being offered for.
  • mule: mule player name or account identifier.
  • world, x, y, floor: mule location.
  • capacity: amount the mule can handle.
  • members: whether the mule can service members worlds.
  • expiresAt, metadata: same purpose as request fields.

MuleAssignment

Sent when a requester or controller chooses a mule.

Important fields:

  • requestId
  • requester
  • mule
  • world, x, y, floor
  • coins
  • expiresAt
  • metadata

MuleStatus

Sent for runtime state updates.

Supported statuses:

  • AVAILABLE
  • ARRIVED
  • TRADE_STARTED
  • TRADE_COMPLETED
  • CANCELLED
  • HEARTBEAT

Worker Example

This example sends a mule request and accepts the first matching assignment.

Replace the placeholder player, world, and coordinate values with your plugin's state.

import com.google.gson.JsonObject;
import com.google.inject.Inject;
import dev.twilite.client.eventbus.Subscribe;
import dev.twilite.client.plugins.Plugin;
import dev.twilite.client.plugins.PluginDescriptor;
import dev.twilite.client.realtime.mule.MuleAssignment;
import dev.twilite.client.realtime.mule.MuleClient;
import dev.twilite.client.realtime.mule.MuleEvent;
import dev.twilite.client.realtime.mule.MuleMessageType;
import dev.twilite.client.realtime.mule.MuleRequest;
import java.util.UUID;

@PluginDescriptor(name = "Worker Mule Example")
public class WorkerMuleExamplePlugin extends Plugin {

private final MuleClient mule;
private String activeRequestId;

@Inject
public WorkerMuleExamplePlugin(MuleClient mule) {
this.mule = mule;
}

private void requestMule() {
activeRequestId = UUID.randomUUID().toString();
JsonObject metadata = new JsonObject();
metadata.addProperty("muleId", "tob");

MuleRequest request = new MuleRequest(
activeRequestId,
"worker-player",
301,
3200,
3200,
0,
5_000_000L,
true,
System.currentTimeMillis() + 120_000L,
metadata
);

mule.request(request).exceptionally(error -> {
log.warn("Failed to publish mule request: {}", error.getMessage());
return null;
});
}

@Subscribe
private void onMuleEvent(MuleEvent event) {
if (event.type() != MuleMessageType.ASSIGNMENT || event.assignment() == null) {
return;
}

MuleAssignment assignment = event.assignment();
if (!assignment.requestId().equals(activeRequestId)) {
return;
}

log.info("Assigned mule {} on world {}", assignment.mule(), assignment.world());
}
}

Mule Example

This example listens for requests and offers service when the request is still valid.

import com.google.gson.JsonObject;
import com.google.inject.Inject;
import dev.twilite.client.eventbus.Subscribe;
import dev.twilite.client.plugins.Plugin;
import dev.twilite.client.plugins.PluginDescriptor;
import dev.twilite.client.realtime.mule.MuleClient;
import dev.twilite.client.realtime.mule.MuleEvent;
import dev.twilite.client.realtime.mule.MuleMessageType;
import dev.twilite.client.realtime.mule.MuleOffer;
import dev.twilite.client.realtime.mule.MuleRequest;

@PluginDescriptor(name = "Mule Example")
public class MuleExamplePlugin extends Plugin {

private final MuleClient mule;

@Inject
public MuleExamplePlugin(MuleClient mule) {
this.mule = mule;
}

@Subscribe
private void onMuleEvent(MuleEvent event) {
if (event.type() != MuleMessageType.REQUEST || event.request() == null) {
return;
}

MuleRequest request = event.request();
if (request.expiresAt() < System.currentTimeMillis()) {
return;
}

JsonObject metadata = new JsonObject();
metadata.addProperty("muleId", "tob");

MuleOffer offer = new MuleOffer(
request.requestId(),
"mule-player",
301,
3210,
3210,
0,
100_000_000L,
true,
System.currentTimeMillis() + 60_000L,
metadata
);

mule.offer(offer).exceptionally(error -> {
log.warn("Failed to publish mule offer: {}", error.getMessage());
return null;
});
}
}

Assignment Strategy

The mule layer does not force a matching algorithm.

Common approaches:

  • Worker chooses the first valid offer.
  • Worker chooses the closest valid offer.
  • A separate controller subscribes to requests and offers, then publishes assignments.

Use requestId to correlate requests, offers, assignments, and status updates.

Use expiresAt and HEARTBEAT status messages to avoid acting on stale clients.

Status Updates

Use status messages to track progress after assignment.

import com.google.gson.JsonObject;
import dev.twilite.client.realtime.mule.MuleStatus;
import dev.twilite.client.realtime.mule.MuleStatusType;

MuleStatus status = new MuleStatus(
activeRequestId,
MuleStatusType.ARRIVED,
"mule-player",
"",
301,
3210,
3210,
0,
System.currentTimeMillis(),
new JsonObject()
);

mule.status(status);

Cancel a request with:

mule.cancel(activeRequestId, "worker-player", "No longer needed");

Practical Rules

  • Generate a unique requestId for every mule request.
  • Ignore expired requests, offers, and assignments.
  • Keep payload metadata small; messages over 16KB are rejected.
  • Do not block the game thread waiting for CompletableFuture completion.
  • Treat realtime as coordination, not durable storage. Store critical state in your plugin if it must survive disconnects.
  • Include enough metadata for your own plugin to reject incompatible requests, such as activity name, required wealth, region, or account role.