IkaScript — Scripting API
Ikar Bot opens its world to Kotlin. A script drives one character through a single bot object — read the world, send commands, wait for events. This is the whole public surface: every method, every model field, every event.
The example plugin
A minimal Gradle project that builds into a working .jar plugin — it attacks the nearest living mob, over and over. Replace the body of run and it's your script.
- MyFarm.kt — the whole script, twenty lines
- The API jars to compile against
- ServiceLoader registration, already wired up
- Gradle wrapper — a JDK 17 is all you need
On this page
A script is Kotlin code that controls one character. Everything goes through the bot: L2Bot object, which is handed to the script on start and is the single entry point into the API.
L2Bot has three kinds of members:
| Kind | Form | Example |
|---|---|---|
| World reads | properties | bot.user.hp, bot.npcs, bot.target |
| Commands | suspend functions returning Boolean | bot.attack(mob), bot.castSkill(1177) |
| Events | a stream | bot.events, bot.waitEvent<…>(…) |
Writing a script
Option 1 — the built-in editor
In the editor you write only the script body. The class and its metadata are generated for you:
override suspend fun run(bot: L2Bot) {
bot.log("Hello, ${bot.user.name} (lvl ${bot.user.level})")
delay(1000)
}These imports are already in place — don't write them:
com.ikar.script.api.*
com.ikar.script.api.model.*
com.ikar.script.protocol.*
kotlinx.coroutines.*You can also override onStop().
This import list is fixed: the body is inserted inside a class, and imports can't be declared there. The whole API, delay, launch, coroutineScope and waitEvent are available right away; Flow operators (filterIsInstance, collect) are not — for those use the jar option.
Option 2 — a .jar plugin
Implement the IkaScript interface in your own project, build a jar and register the implementation via ServiceLoader — put a META-INF/services/com.ikar.script.api.IkaScript file in the jar containing the fully qualified name of your class.
class MyFarm : IkaScript {
override val meta = ScriptMeta(id = "my-farm", name = "My Farm")
override suspend fun run(bot: L2Bot) {
while (true) {
val mob = bot.npcs.nearest { it.attackable && !it.dead }
if (mob != null && bot.user.inRange(mob, 800)) bot.attack(mob)
delay(500)
}
}
override fun onStop() { /* cleanup, optional */ }
}Drop the jar into the scripts directory (data/scripts by default) or open it from the application manually.
The IkaScript interface
| Member | Type | Description |
|---|---|---|
meta | ScriptMeta | Script metadata. |
run(bot: L2Bot) | suspend fun | The script body. All of your logic lives here. |
onStop() | fun | Called when the script stops. Does nothing by default. |
The ScriptMeta class
| Field | Type | Default | Description |
|---|---|---|---|
id | String | — | Script identifier. |
name | String | — | Display name. |
version | String | "1.0" | Version of the script itself; does not affect loading. |
apiVersion | Int | current API version | Version of the API contract the plugin was built against. |
A plugin whose apiVersion does not match the application's API version is not loaded. The current version is ScriptApi.VERSION = 1; the field fills itself in, you never set it by hand.
Three rules that save hours of debugging
1. Entities are a snapshot, not a live reference
Every read of bot.user / bot.npcs / bot.drops returns the current picture of the world. But the object you get is a snapshot taken at read time — it never updates itself. After a delay or any command its data is stale.
// Wrong: mob is read once, so its hp is frozen from here on
val mob = bot.npcs.nearest { it.attackable }!!
while (mob.hp > 0) { // this condition will never change
bot.attack(mob)
delay(500)
}
// Right: re-read the world on every iteration
while (true) {
val mob = bot.npcs.nearest { it.attackable && !it.dead } ?: break
bot.attack(mob)
delay(500)
}2. Compare entities by oid, not with ==
Objects are rebuilt on every world update, so == (reference equality) returns false even for the very same mob.
if (bot.target?.oid == mob.oid) { … } // right
if (bot.target == mob) { … } // this will not work3. true doesn't always mean "it worked"
Some commands wait for the outcome in game, others just send the action without waiting for confirmation — those always return true. What a specific method's return value means is spelled out in the "Returns" column on the Commands page.
There is no auto-retry: if a command returned false, retrying is the script's decision.
id or oid — commands accept both
id(template id) is a type: a mob id, an item'sitemId, askillId. The number you see in game and in guides. Every orc on the map shares it.oid(object id) is a specific instance: this orc, this item in your bag, this drop on the ground. Issued by the server, alive as long as the object is, and carried in event fields.
A plainly named method (setTarget, attack, pickup, useItem, destroyItem, openDialog, …) first looks the number up as a template id among nearby objects. If nothing matches, it treats the number as an object id and sends it as is.
bot.setTarget(20001) // mob type → nearest living mob of that type
bot.setTarget(mob.oid) // object id → exactly this mob
bot.setTarget(mob) // entity → no guessing at all
bot.destroyItem(57, 1000) // 57 = adena (template id)
bot.pickup(57) // nearest adena on the groundA collision is impossible: the server issues object ids starting at 0x10000000, while template ids are in the thousands. A number that matches nothing visible is not discarded: an oid from a just-received event can be ahead of the world snapshot, so it goes to the game as is.
When you need to be unambiguous:
| Form | What it does |
|---|---|
entity overload — pickup(drop), destroyItem(item, n), openDialog(npc) | Exactly this object. The most reliable path. |
*ByOid — pickupByOid(oid), setTargetByOid(oid), destroyItemByOid(oid, n) | Strictly an object id. |
*ByType — setTargetByType(npcId), useItemByType(itemId), destroyItemByType(itemId, n) | Strictly a template id. |
Stopping a script
Stopping interrupts run at the nearest waiting point — a delay, or any suspend command. You don't need to handle an infinite while (true) yourself.
If something has to be cleaned up afterwards (drop the target, stand up, switch automation back on), use try/finally or onStop():
override suspend fun run(bot: L2Bot) {
bot.disableAutomation()
try {
while (true) { … }
} finally {
bot.enableAutomation()
}
}Walking in progress (moveTo, moveToByGeo) is aborted when the script stops — the character won't keep following a route with no script behind it.
Scripts and built-in automation
A script and the built-in bot (the one you configure in the application) run in parallel and independently: automation never cancels the script's commands, and the script's commands never break automation.
But there is only one character. With automation on, it will be steering the character towards its own goals at the same time — during a long walk or a complex scenario that looks like the character is torn between two masters. The script acts as the director here:
bot.disableAutomation()
bot.moveToByGeo(x, y, z, timeoutMs = 0)
bot.enableAutomation()Where to start
override suspend fun run(bot: L2Bot) {
bot.log("Started: ${bot.user.name}")
while (true) {
val me = bot.user
if (me.dead) {
bot.log("Dead — waiting")
delay(3000)
continue
}
// pick up my own drop at my feet
bot.drops.nearest { it.isMine && it.distToSelf <= 200 }?.let { bot.pickup(it) }
// attack the nearest mob
val mob = bot.npcs.nearest { it.attackable && !it.dead && it.distToSelf <= 1500 }
if (mob != null) {
bot.log("Attacking ${mob.name} at ${mob.distToSelf} units")
bot.attack(mob)
bot.waitEvent<ScriptEvent.Died>(15_000) { it.oid == mob.oid }
}
delay(500)
}
}