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:

StepNPC optionObject option
1NpcInteractEvent is published to modulesPlayer walks into range (Interact Distance, default 2 tiles), turns to face the object, then ObjectInteractEvent is published
2The NPC interaction module runs your NpcScript.OnClick / OnOptionIf no module cancelled or handled it, your ObjectScript.OnClick / OnOption runs
3If the script returned false: attack starts combat; anything else is logged as unhandledIf the script returned false: task triggers run (object_click in the Task editor)
4Task triggers run (npc_click) unless the event was cancelledOtherwise 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 editoroption received by the script
Attackattack
Talk-totalkto
Searchsearch
Pick uppick_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#

  1. Open the Data editor and select the NPC or object.
  2. In the General tab expand Interaction Options (NPCs) or Interact Actions (objects).
  3. Pick an entry from the dropdown — NPCs offer Attack, Talk-to and Trade; objects offer Examine, Open, Close, Search, Use, Take, Enter and Unlock — or choose Custom... and type a label. Adding Attack to an NPC also turns on Attackable and switches to the Combat tab.
  4. Drag the handle to reorder. The first option is the default left-click.
  5. Rows for Talk-to show a dialogue picker; choosing a dialogue there wires the option to the Dialogue system with no script.
  6. 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 classFolderBound from
NpcScriptserver/scripts/npcs/The NPC's Script: picker (General tab)
ObjectScriptserver/scripts/objects/The object's Script: picker, or Edit Method on any option row
ItemScriptserver/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>.cs with an OnOption method containing an if block 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>.cs and 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:

JSON
{
  "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#

C#
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#

MemberCalled whenDefault
int[] NpcIdsLoad time; ids to serve when no binding existsempty
bool OnClick(ScriptPlayer, NPC, string option)A player picks an option; default forwards to OnOptionforwards
bool OnOption(ScriptPlayer, NPC, string option)Via OnClickfalse
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 server25 (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 themnull
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#

MemberCalled whenDefault
int[] ObjectIdsLoad time; ids to serve when no binding existsempty
bool OnClick(ScriptPlayer, WorldObject, string option)A player in range picks an option; forwards to OnOptionforwards
bool OnOption(ScriptPlayer, WorldObject, string option)Via OnClickfalse
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 = "") and ScriptWorld.SpawnNpc(npcId, region, x, y, z, instance = "") create new instances; footprints come from the definition.

ItemScript reference#

MemberCalled when
int[] ItemIdsLoad 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:

CallEffect
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 / IncrementVarPersistent 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:

C#
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:

WantData-driven toolScript alternative
Conversation, branching choices, set quest flagsTalk-to option + dialogue picker → DialogueNpcScript with Say / AskChoice
Loot from searching, opening, mining an object, or from using an item on itLoot tables with option or item_on triggersObjectScript.OnOption + player.GiveItem / GiveLoot
Loot on NPC deathNPC drop tablesNpcScript.GetDrops
A repeating, interruptible activity (chop, mine, fish)Tasks with an object_click trigger and break flagsObjectScript + hand-rolled player.Every loop
A buyable stockShops + openShop dialogue actionShopRegistry.Instance.OpenShop from a script
NPC that fights backCombat Scripts tool (attacks, phases)NpcCombatScript
Something happens when a player enters an areaLocations painted in the Map EditorA 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 OnStartOnTick every delay_ticksOnStop. 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#

SymptomCause and fix
Clicking the option does nothing and the server logs Unhandled NPC interaction / No handler for objectThe 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: 0Not 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#### errorsThe 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 scriptThe 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 runsA 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 objectNo ItemScript.OnUseOnObject, item_on loot trigger or item_on_object task claimed it.
NPC is invisible in the client after editing optionsThe 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.

Spotted a mistake or something missing?Tell us on Discord