NPC, Object and Interaction Scripts
Attach C# scripts to NPC and object options, handle clicks and item-on-object use, and choose between scripts and data-driven actions.
12 min read
How an interaction reaches your script#
Every NPC and object definition carries a list of options — the entries players see in the right-click menu (Attack, Talk-to, Open, Search, ...). When a player picks one, the client sends the option id plus the entity's index and definition id to the server. The server then walks a fixed chain of handlers and stops at the first one that claims the click:
| Step | NPC option | Object option |
|---|---|---|
| 1 | NpcInteractEvent is published to modules | Player walks into range (Interact Distance, default 2 tiles), turns to face the object, then ObjectInteractEvent is published |
| 2 | The NPC interaction module runs your NpcScript.OnClick / OnOption | If no module cancelled or handled it, your ObjectScript.OnClick / OnOption runs |
| 3 | If the script returned false: attack starts combat; anything else is logged as unhandled | If the script returned false: task triggers run (object_click in the Task editor) |
| 4 | Task triggers run (npc_click) unless the event was cancelled | Otherwise No handler for object is logged |
The template's Dialogue, Drops and Quests modules all listen at step 1, which is why a Talk-to option with a dialogue attached never needs code, and why a loot table on a chest's Search option fires before any script (the drop module marks the event Handled).
Inventory items follow a similar chain: ItemUseEvent → task triggers (item_click) → ItemScript.OnOption → built-in defaults (equip, drop, deposit, shop sell-N). Using an item on an object publishes ItemOnObjectEvent, then ItemScript.OnUseOnObject, then item_on_object task triggers, and finally prints Nothing interesting happens. if nobody claimed it.
The option string you compare against#
Client-side option ids are derived from the label you typed in the Data Editor: lowercased, hyphens removed, spaces replaced by underscores.
| Label in the editor | option received by the script |
|---|---|
Attack | attack |
Talk-to | talkto |
Search | search |
Pick up | pick_up |
The client always appends an Examine option to NPCs and objects that do not define one; it arrives as examine.
Add options to an NPC or object#
- Open the Data editor and select the NPC or object.
- In the General tab expand Interaction Options (NPCs) or Interact Actions (objects).
- Pick an entry from the dropdown — NPCs offer
Attack,Talk-toandTrade; objects offerExamine,Open,Close,Search,Use,Take,EnterandUnlock— or choose Custom... and type a label. AddingAttackto an NPC also turns on Attackable and switches to the Combat tab. - Drag the
≡handle to reorder. The first option is the default left-click. - Rows for
Talk-toshow a dialogue picker; choosing a dialogue there wires the option to the Dialogue system with no script. - Click Save NPC / Save Object. Labels go to the client data file; ids, labels and dialogue ids go to the server data file (see NPCs and Objects).
Attach a script#
Scripts live under server/scripts/ and extend one of three base classes:
| Base class | Folder | Bound from |
|---|---|---|
NpcScript | server/scripts/npcs/ | The NPC's Script: picker (General tab) |
ObjectScript | server/scripts/objects/ | The object's Script: picker, or Edit Method on any option row |
ItemScript | server/scripts/items/ | The item's script field (separate from the equipment script used for worn effects) |
Step 1: Create the file#
Either of these works:
- In the Data Editor, click + New next to Script: (NPCs and objects) or Edit Method on an option row. The editor writes
Npc_<id>.cs/Object_<id>.cswith anOnOptionmethod containing anifblock per option, binds it, and opens it in the Script editor. Adding another option later appends a matching block to the existing file. - In the Script editor, click New Script… and pick NPC Script, Object Script or Item Script. The dialog shows the destination (
server/scripts/npcs/<Name>.csand so on) and a preview.
Step 2: Bind it#
A script does not know which definitions it serves. The binding is stored in server/data/script_bindings.json, which the editor regenerates from every definition's script_path each time you save one:
{
"npcs": { "3": "server/scripts/npcs/Druid.cs" },
"npc_combat": { "2": "server/scripts/combat/BruteCombat.cs" },
"objects": { "13": "server/scripts/objects/TreasureChest.cs" },
"items": { "15": "server/scripts/items/VigorPotion.cs" },
"equipment": { }
}
The server matches each compiled file by file name against these paths, so one script can serve several ids and you can move files between subfolders freely. Pick the script in the definition's Script: picker and save the definition — until you save, the binding is not on disk. A script with no binding falls back to the ids it hardcodes in its NpcIds / ObjectIds / ItemIds property; leave those empty when you bind through the editor.
The Script editor's Used By panel lists which definitions reference the open file.
Step 3: Write the handler#
public class TreasureChest : ObjectScript
{
const int CHEST_OPEN_ID = 14; // a second object definition with the open model
public override bool OnOption(ScriptPlayer player, WorldObject obj, string option)
{
if (option == "search")
{
if (GetVar(obj, "looted", false))
{
player.SendGameMessage("The chest is empty.");
return true;
}
SetVar(obj, "looted", true);
player.GiveItem(7, 50); // 50 coins
player.PlayAnimation(12);
ScriptWorld.SetObjectState(obj, CHEST_OPEN_ID, 100, obj.Id); // swap back after 25 s
player.After(100, () => SetVar(obj, "looted", false));
return true;
}
return false; // let task triggers have a go
}
}
Return true when you handled the click. Returning false lets the chain continue (task triggers for objects; the built-in attack behavior for NPCs).
NpcScript reference#
| Member | Called when | Default |
|---|---|---|
int[] NpcIds | Load time; ids to serve when no binding exists | empty |
bool OnClick(ScriptPlayer, NPC, string option) | A player picks an option; default forwards to OnOption | forwards |
bool OnOption(ScriptPlayer, NPC, string option) | Via OnClick | false |
void OnSpawn(NPC) | The NPC system respawns this NPC after death | — |
void OnDeath(NPC, Entity? killer) | The NPC dies | — |
void Process(NPC) | Every tick (4 per second) while the NPC is alive | — |
int GetMaxHit(NPC) | Combat asks for the max hit; return -1 for the formula | -1 |
int GetAttackSpeed(NPC) | Ticks between attacks; -1 for default | -1 |
float GetAccuracy(NPC) | 0 to 1; -1 for default | -1 |
void OnDamageTaken(NPC, Entity attacker, int damage) | After damage is applied | — |
int GetRespawnTicks() | Respawn delay after death. A bound script's value always wins; the definition's own Respawn Ticks field is not read by the current server | 25 (about 6 s) |
List<(int itemId, int amount)>? GetDrops(NPC, Entity? killer) | On death; return a list to replace the loot tables, null to use them | null |
SetVar(npc, key, value) / GetVar<T>(npc, key, default) / HasVar(npc, key) | Any time; per-NPC-instance runtime state, cleared when the NPC is removed | — |
An NPC gives you Id (definition), Index (instance), Name, Position, Region, InstanceId, Health, MaxHealth, Size, SpawnPosition, ForceSay(text) for an overhead line, and Transform(newId) to change its definition in place. ScriptWorld.FindNpc(id) / FindNpcs(id) / GetNpcsInRegion(region) return the lighter ScriptNpc wrapper (Index, Id, Position, Region, Health, MaxHealth, IsAlive, ForceSay).
ObjectScript reference#
| Member | Called when | Default |
|---|---|---|
int[] ObjectIds | Load time; ids to serve when no binding exists | empty |
bool OnClick(ScriptPlayer, WorldObject, string option) | A player in range picks an option; forwards to OnOption | forwards |
bool OnOption(ScriptPlayer, WorldObject, string option) | Via OnClick | false |
void OnSpawn(WorldObject) | Declared on the base class, but never invoked by the current server | — |
void Process(WorldObject) | Every tick | — |
SetVar(obj, key, value) / GetVar<T>(obj, key, default) / HasVar(obj, key) | Per-object-instance runtime state | — |
A WorldObject exposes Id, Index, Position (its south-west tile), Region, InstanceId, Width, Depth, EffectiveWidth / EffectiveDepth (rotation-aware), RotationY, HeightLayerId, InteractDistance, CollisionType, FromMapSpawn and ForceSay(text).
State changes go through ScriptWorld:
ScriptWorld.SetObjectState(obj, newObjectId)swaps the object to another definition (tree → stump).ScriptWorld.SetObjectState(obj, newObjectId, respawnTicks, respawnToObjectId)swaps and schedules the swap back.ScriptWorld.SpawnObject(objectId, region, x, y, z, size = 1, instance = "")andScriptWorld.SpawnNpc(npcId, region, x, y, z, instance = "")create new instances; footprints come from the definition.
ItemScript reference#
| Member | Called when |
|---|---|
int[] ItemIds | Load time fallback ids |
bool OnOption(ScriptPlayer, int itemId, int slot, string option) | An inventory option that no module or task claimed; return true to replace the built-in equip / drop / deposit handling |
bool OnUseOnItem(ScriptPlayer, int itemId, int itemSlot, int targetItemId, int targetSlot) | Item used on another inventory item |
bool OnUseOnNpc(ScriptPlayer, int itemId, int itemSlot, NPC npc) | Item used on an NPC |
bool OnUseOnObject(ScriptPlayer, int itemId, int itemSlot, WorldObject obj) | Item used on a world object |
EquipmentStats GetStats(int itemId) | Equipment bonuses (accuracy, strength, defence for melee, ranged and magic) |
OnUseOnObject is the code path for "use key on door" or "use fish on fire". The same interaction can be data-driven with an item_on trigger on an object loot table or an item_on_object task trigger; see the comparison below.
Talking to the player#
The ScriptPlayer you receive is the full scripting surface for that player (see the Script API reference). The pieces you reach for in interaction scripts:
| Call | Effect |
|---|---|
player.SendGameMessage(text) | Line in the player's chatbox |
player.Say(text) | Overhead bubble over the player |
player.Narrate(text, p => ...) | Narration panel; the callback runs when the player continues |
player.SetDialogueActor(npcWrapper) then player.Say(text, p => ...) | Bubble anchored above the NPC or object, continue-to-advance |
player.AskChoice(prompt, ("Yes", p => ...), ("No", p => ...)) | Clickable choices |
player.AskInput(prompt, (p, text) => ...) | Free-text input |
player.GiveItem(id, amount) / RemoveItem / HasItem / GiveLoot("table_id") | Inventory |
player.Teleport(region, x, z, heightLayer) | Move the player |
player.OpenInterface(name) / SetInterfaceText(...) | Drive a GUI screen (Custom interfaces) |
player.SetVar / GetVar / IncrementVar | Persistent per-character flags |
player.After(ticks, action) / Every(ticks, action) | Timers bound to the player |
ScriptWorld.SendMessage(text) | Chatbox line for every online player |
ScriptWorld.OpenInterfaceForAll(id) / CloseInterfaceForAll(id) | Screens for everyone |
ShopRegistry.Instance.OpenShop(player, "shop_id") | Open a shop defined in the Shops tool |
Example of a scripted conversation that opens a shop:
public class Engineer : NpcScript
{
public override bool OnOption(ScriptPlayer player, NPC npc, string option)
{
if (option != "talkto") return false;
player.SetDialogueActor(ScriptWorld.FindNpcByIndex(npc.Index));
player.Say("Need tools? I sell the best in town.", p =>
p.AskChoice("What do you say?",
("Show me", q => _ = ShopRegistry.Instance.OpenShop(q, "engineers_tools")),
("Not today", q => q.ClearDialogueActor())));
return true;
}
}
Scripts versus data-driven actions#
Most common interactions already have a no-code path. Reach for a script only when none of these fit:
| Want | Data-driven tool | Script alternative |
|---|---|---|
| Conversation, branching choices, set quest flags | Talk-to option + dialogue picker → Dialogue | NpcScript with Say / AskChoice |
| Loot from searching, opening, mining an object, or from using an item on it | Loot tables with option or item_on triggers | ObjectScript.OnOption + player.GiveItem / GiveLoot |
| Loot on NPC death | NPC drop tables | NpcScript.GetDrops |
| A repeating, interruptible activity (chop, mine, fish) | Tasks with an object_click trigger and break flags | ObjectScript + hand-rolled player.Every loop |
| A buyable stock | Shops + openShop dialogue action | ShopRegistry.Instance.OpenShop from a script |
| NPC that fights back | Combat Scripts tool (attacks, phases) | NpcCombatScript |
| Something happens when a player enters an area | Locations painted in the Map Editor | A LocationScript for one location, or a module subscribing to PlayerLocationEnterEvent (Events and systems) |
ObjectScript or task?#
A TaskScript<ScriptPlayer> is registered in the Task editor with a trigger (object_click, npc_click, item_click, command, location_enter, item_on_object, item_on_npc, item_on_item, server_startup, player_join, npc_spawn, object_spawn) and runs OnStart → OnTick every delay_ticks → OnStop. Its break conditions (break_on_movement, break_on_damage, break_on_death, break_on_teleport, break_on_combat, break_on_equip_change in the task data) cancel it automatically, and the TaskContext hands you Object, Npc, ItemId, Option and Args. Use a task for anything that lasts more than one tick. Use an ObjectScript for instant reactions (open, examine, toggle a lever, teleport), or to add flavor text before falling through to a task — an ObjectScript that returns false still lets the object_click trigger fire.
Troubleshooting#
| Symptom | Cause and fix |
|---|---|
Clicking the option does nothing and the server logs Unhandled NPC interaction / No handler for object | The script returned false for that option, or the option string does not match (check the derivation rule above). |
Script never runs, console shows NPC Scripts: 0 | Not bound. Pick the script in the definition's Script: picker and save the definition so script_bindings.json is rewritten; confirm the file name in the binding matches the file on disk. |
[ScriptManager] Compiling ... followed by CS#### errors | The file is skipped until fixed. Build the companion project from IDE setup to see errors before launching. |
Talk-to opens a dialogue on top of your script | The Dialogue module handles talkto at HIGH priority, which runs after the NPC interaction module has already called your script, and it opens the bound dialogue whatever your script returned. Remove the dialogue from the option row, or keep the dialogue and drop the script branch. |
| A chest gives loot and the script never runs | A loot table with an option trigger marked the event Handled before the script. Remove the trigger or move the logic into the table. |
Nothing interesting happens. when using an item on an object | No ItemScript.OnUseOnObject, item_on loot trigger or item_on_object task claimed it. |
| NPC is invisible in the client after editing options | The client needs option labels as plain strings and a project-relative model path; re-save the NPC from the Data Editor. |
With AI (MCP)#
write_server_script with kind: "npc", "object" or "item" writes a correctly shaped script into the matching server/scripts/ subfolder; update_npc and update_object accept script_path (and combat_script_path for NPCs) and also rewrite script_bindings.json. The data-driven alternatives map to define_dialogue, set_drop_table, define_task, define_shop and define_boss.
