Skip to content

Mobile scripts

This page is part of Writing Lua scripts. The functions a script calls on its NPC are in the reference: npc and mobile. Every hook the server calls, with its arguments and whether it runs inside the action, is in the single list of Script hooks.

A mobile template names its script with script_id, the name of a global Lua table defined by scripts/mobiles/<script_id>.lua. The server loads every *.lua directly in that directory at startup, in name order, after init.lua; a script that fails to load is reported like any script error, and the server starts with the others.

templates/mobiles/animals.toml
[[mobile]]
id = "cat"
name = "a cat"
body = 201
script_id = "wander"

The table may define these functions; each one is optional:

Function When
on_think(serial) On every think of the NPC: every ultima.npcs.think_interval_ms (500 ms by default) while a player is within the 5×5 sectors around it; see NPC tick. A think is instantaneous, as ModernUO’s: it must not call wait (the server warns once per script), so keep the timing in the script, for example by counting thinks.
on_speech(serial, speaker, text, keywords, type) When a player says text within hearing (commands are not heard): 15 cells aloud or as an emote, 1 cell for a whisper, 18 for a yell. speaker is the player’s serial; keywords the speech keywords the client found, an array of numbers whatever its language, such as SpeechKeywordType.Bank; type how it was said, a number to compare with SpeechType.Regular, SpeechType.Emote, SpeechType.Whisper or SpeechType.Yell. It may call wait.
on_spawn(serial) Once, right after the NPC is spawned (.spawn), in the world with its items and shown, before any other function of its script. Not when the saved NPCs are loaded at startup. It may call wait.
on_mobile_in_range(serial, other) Each time another mobile, player or NPC, comes within ultima.npcs.sense_range cells (8 by default, a square along X and Y) by a step or by entering the world. Once per arrival: it fires again only after the mobile has left the range and come back. Both ways: an NPC walking toward a mobile senses it too. NPCs loaded together at startup do not sense each other until one moves out of range and back. other is its serial; npc.name(other) gives nil for a player. It may call wait.
on_mobile_killed(serial, killed, killer) When a mobile, a player or an NPC, is killed within ultima.npcs.sense_range cells of this NPC (8 by default, a square along X and Y), the killed one excepted. serial is the NPC that is told, killed the serial of who died and killer the serial of who killed it, or nil when nobody did. npc.name(killed) gives nil for a player. A dying NPC is not told.
on_death(serial, corpse, killer) When the NPC dies (Death and resurrection), after its corpse exists and is shown and before the NPC leaves the world, so it can still be read. corpse is the serial of the corpse, nil when none was made; killer the serial of who killed it, nil when nobody did. Killed by a script, it runs on the next turn of the game loop, just before the NPC is removed; it must not wait().
on_health_low(serial, hits, hits_max, attacker) When the NPC’s hit points fall under its template’s low_health_at percent from damage that does not kill it. Once: it fires again only after the hit points recover to 15 points of percent above the threshold. attacker is the serial of who dealt the damage, 0 when none (a poison, a field). See Low health and low mana. Runs on the next turn of the game loop and may call wait(). After resuming, check that the NPC is still in the world, for example with npc.name(serial) ~= nil, before acting on it.
on_mana_low(serial, mana, mana_max, needed) When the mana of an NPC that casts falls under needed, what its cheapest spell costs. Once: it fires again only after the mana recovers to needed plus the cost of one more such spell, or to half the maximum, whichever is lower. See Low health and low mana. Runs on the next turn of the game loop and may call wait(). After resuming, check that the NPC is still in the world, for example with npc.name(serial) ~= nil, before acting on it.
on_damage(serial, attacker, amount, kind) Just before the NPC loses hit points: a blow, a shot, a spell, a poison tick, a field. Return a number to replace the damage, false for none. It runs at once, also inside a spell script, and must not wait(). See Fights and life.
can_die(serial, killer) A blow or a poison tick would leave the NPC with no hit points. Return false to keep it alive at 1 hit point. At once; must not wait(). See Fights and life.
on_heal(serial, healer, amount) Before a bandage or a heal spell gives the NPC hit points back (mobile.heal). Return a number or false, as on_damage.
on_attack(serial, target) The NPC is about to swing. Return false to cancel this swing.
on_hit(serial, target, damage), on_miss(serial, target) The NPC’s blow hurt, or missed. Queued.
on_combat_start(serial, target), on_combat_end(serial, target, reason) The NPC got a target from none, or lost it (killed, lost, expired, fled, stopped). Queued.
on_resurrect(serial, healer) The NPC is back from its corpse. Queued.
on_tame(serial, tamer), on_release(serial, owner) The creature became a player’s own, or is nobody’s any more. Queued.
on_enter_region(serial, region), on_leave_region(serial, region) The NPC crossed the boundary of a named region. Queued.
on_drag_drop(serial, giver, item) When a player drops an item on the NPC from 2 tiles or closer, on the same map; the staff from any distance, as in ModernUO. From farther away a player reads That is too far away. and the item comes back. giver is the player’s serial, item the item’s. Return true when the script took the item, having moved or deleted it (item.delete, item.move_into, item.move_to): the item is on no cursor while the function runs. Anything else, a missing function, an error or a wait() gives the item back to where the player lifted it from, as every drop on an NPC without this function. The item’s own can_drop is asked first, and its on_drop is not called for an item an NPC took.
on_context_menu(serial, player) When that player asks for the NPC’s context menu. Return the entries the NPC adds, a table of { id, cliloc, range, enabled }, or nothing; it answers at once and must not wait.
on_context_menu_select(serial, player, id) When the player chose one of those entries: id is the entry’s own. Called only for an entry the player was shown and is in range of. It may call wait.

on_spawn, on_mobile_in_range, on_mobile_killed, on_health_low and on_mana_low, and the notices of Fights and life, run right after what caused them, on the next turn of the game loop: a step made by npc.step inside a running handler cannot start another script at once. No function runs before the scripts are loaded at startup, which is after the saved NPCs enter the world.

Scripts act on their NPC with the npc module, passing its serial. A serial that is not an NPC in the world, such as a removed NPC or a player, gives false or nil, never an error: a handler that waited may outlive its NPC, and a script can never voice or move a player.

An NPC that takes what it is given, and keeps only gold:

collector = {}
function collector.on_drag_drop(serial, giver, given)
if item.template(given) ~= "gold_coin" then
npc.say(serial, "I take gold only.")
return false
end
npc.say(serial, "Thank you.")
item.delete(given)
return true
end

The distribution’s scripts/mobiles/wander.lua, copied into the root by mgctl:

wander = {}
local thinks = {}
function wander.on_think(serial)
thinks[serial] = (thinks[serial] or 0) + 1
if thinks[serial] % 4 == 0 then
npc.wander(serial)
end
end
function wander.on_speech(serial, speaker, text)
if text:lower():find("hello", 1, true) then
npc.look_at(serial, speaker)
wait(1)
npc.say(serial, "Well met, traveller.")
end
end
function wander.on_spawn(serial)
npc.say(serial, "*stretches*")
end
function wander.on_mobile_in_range(serial, other)
if npc.name(other) == nil then
npc.say(serial, "Who goes there?")
end
end

No template in the repository uses it: add script_id = "wander" to a mobile template to try it. An NPC spawned by a spawn region carries its home area in the props spawn.x1, spawn.y1, spawn.x2 and spawn.y2, which npc.home gives as a table: npc.wander only steps inside it, and walks the NPC back when it is outside. It strolls as ModernUO’s creatures do, mostly straight ahead, where the script before picked a new direction at every step.

A script’s local tables live in memory: they start again empty after a restart or a reload. To remember something across restarts, keep it in the NPC’s props, prefixing the key with the script name, as vega.lua counts the hellos it hears:

function vega.on_speech(serial, speaker, text)
if text:lower():find("hello", 1, true) then
npc.look_at(serial, speaker)
local times = (npc.get_prop(serial, "vega.greeted") or 0) + 1
npc.set_prop(serial, "vega.greeted", times)
npc.say(serial, "Meow! That's " .. times .. " hellos.")
end
end

A change made after the last world save is lost if the server stops without saving.

Reload one script with script reload mobiles/wander.lua. Its table is replaced, so the NPCs use the new functions from their next think; state kept in local tables of the old file starts again, and the waits its handlers left are cancelled, because a script’s calls belong to mobiles/<script_id>.lua. When the server stops, the scripts are no longer called, before the script engine stops.

The script of the monsters, monster.lua, is described with the other shipped scripts.

Two hooks tell a script that its NPC is running low, so it can call for help, drink a potion, retreat or change tactics. They are hooks, not behaviour: the shipped common/creature.lua keeps its own flee_at logic and does not use them.

brute = {}
function brute.on_health_low(serial, hits, hits_max, attacker)
npc.say(serial, "Enough of this!")
if attacker ~= 0 then
-- call for the others, drink, or turn away from `attacker`
end
end
function brute.on_mana_low(serial, mana, mana_max, needed)
-- out of mana for even the cheapest spell: fall back to melee
end

Both events follow the same rules:

  • They are edge-triggered. The event fires once, as the value crosses from above its low level to below it. It does not fire again while the value stays low, or while it oscillates around the line: it is armed again only when the value recovers past a higher level (the hysteresis).
  • An NPC that is already low when it is first seen, such as one loaded from a saved world, does not fire: it fires only after it has recovered and crosses again. The state is not saved.
  • Only NPCs with a template and a script hear them, never players. They are queued like on_death, on the next turn of the game loop, and never run inside the damage or the cast that caused them. An event that is due after the NPC died or was removed, such as a crossing followed by a killing blow in the same pass, is dropped. A script that does not define the function costs nothing.
  • Every source of change goes through the same place, so they all count: a melee blow, an arrow, a spell, a poison tick, a field, the mana a spell costs, Mana Drain and Mana Vampire, mobile.set_stats and the .set command.

on_health_low fires when hits * 100 < hits_max * low_health_at, from damage that does not kill (a killing blow is on_death, not this), and only when the hit points went down. The template’s low_health_at is a percent from 0 to 100 (25 when unset, 0 to disable), inherited through base_id. It is armed again when the hit points reach low_health_at plus 15 percentage points (100 at most) of the maximum. A change of the maximum does not fire by itself: the next damage under the line does.

on_mana_low fires when the mana falls under needed, the mana of the cheapest spell the NPC can cast; an NPC that casts nothing, with no spell_circles in its template, no Magery or no mana, never hears it (a creature with Magery and mana but no spell_circles casts nothing, so it is not told it is short). needed is passed so the script need not compute it. It is armed again at twice needed or at half of mana_max, whichever is lower, and never under needed + 1 (a mana_max of needed itself re-arms at needed). It also fires after a drain or any other loss of mana, not only a cast. A pet raised from its corpse comes back with no mana: that is not an event, and it hears on_mana_low only after its mana has regenerated above the re-arm level and fallen again.

The hooks of a fight and of a creature’s life are listed, with their exact arguments and rules, in Script hooks. Three of them change what happens, so they run at once, before the change, and a script that fails counts as having no such function:

golem = {}
-- A stone body takes half of the blows and none of the poison; a fire trap or a field is not softened.
function golem.on_damage(serial, attacker, amount, kind)
if kind == "poison" then
return false
elseif kind == "melee" or kind == "ranged" or kind == "spell" then
return math.floor(amount / 2)
end
end
-- A boss that gets up once.
function golem.can_die(serial, killer)
if npc.get_prop(serial, "second_life") == nil then
npc.set_prop(serial, "second_life", true)
mobile.set_stats(serial, { hits = 40 })
return false
end
end
-- A swing that does not happen while the NPC is busy casting.
function golem.on_attack(serial, target)
return npc.get_prop(serial, "casting") == nil
end
  • kind is melee, ranged, spell, poison or other. Scripts name it with the optional last argument of combat.harm(target, damage, attacker, kind): spell when left out, other for a field, an explosion or a trap. The poison and the blows of the server name their own.
  • A damage of 0, from the hook or from armor, still plays the hurt animation: the NPC reacts to a blow that did nothing, and it raises neither on_hit nor on_miss.
  • can_die refuses only a lethal blow or poison tick, after on_damage has had its say: the NPC is left with 1 hit point, fights back as for any blow that did not kill, may hear on_health_low, and the next lethal blow asks again. A poisoned NPC kept at 1 hit point keeps ticking. .kill and mobile.kill do not ask. Players are never asked.
  • A handler that damages, heals or kills its own NPC is not called again for that: the nested call goes through as if the script had no hook. After on_damage the server looks again: if the handler cured the poison, killed the NPC or removed it, the blow or the tick ends there, and can_die is not asked for an NPC that has no hit points left.
  • An NPC that dies, or is killed with .kill or mobile.kill, ends every fight against it for the NPCs that fought it with on_combat_end and the reason killed, also when it leaves the world at once.
  • on_attack returning false spends the swing’s delay and does nothing else, so it is asked again when the next swing is due; so does one that ended the NPC’s own fight or turned it on another target (combat.stop, combat.attack): that swing is not made. Changing target in a fight raises neither on_combat_start nor on_combat_end; an NPC that dies raises no on_combat_end, on_death tells it.
  • The queued hooks run after the change, on a later turn of the game loop, so a handler sees the world as it is then, and they are dropped for an NPC that died or left the world meanwhile.
  • on_heal is asked by mobile.heal(patient, amount, healer), which the bandages and the heal spells use, and only when something can be healed: not for an NPC at its maximum or an amount of 0. Regeneration, food, potions and mobile.set_stats are not heals.
  • on_enter_region and on_leave_region carry the name of the region, as world.region gives it; leaving one for another raises the leave first. An NPC whose script defines neither costs nothing for its steps.

npc.walk_to lets an NPC reach a place around walls, water and cliffs, and through the closed doors it opens: a human or a monster opens a door that is not locked, an animal or a sea creature does not, unless its mobile template says so with opens_doors = true or false. The script calls it on every tick and the NPC takes one step each time:

guard = {}
function guard.on_think(serial)
local state = npc.walk_to(serial, 1434, 1699)
if state == "arrived" then
npc.say(serial, "All quiet at the bank.")
end
end
Answer Means
"moving" The NPC took a step, or asked a door in its way to open: it passes at a next call, and after three calls at a door that stays shut the answer is "blocked"
"arrived" It stands within range tiles of the place (default 0), at its height; it is not checked that nothing stands between them
"blocked" The step was refused, or the NPC waits to look for another way
"no_path" The last search did not reach the place: nothing leads there, or only somewhere near
nil The serial is not an NPC, range is negative or z is outside -128 to 127

The path is found with the server’s path search and kept for the NPC, so most calls only take the next step. A search runs when the NPC has no steps left or the place changed, and only:

  • two seconds after the NPC’s last search, as ModernUO; ten seconds when that search did not reach the same place from where the NPC stands, since such a search is the costly kind;
  • for ten NPCs a second in the whole server; the others wait their turn.

While it may not search, an NPC goes on along the path it has, or with none steps straight towards the place, so one that chases something keeps moving. A place that cannot be reached is walked towards as far as a path leads. To follow someone, pass where it stands on every tick and a range of 1 to stop beside it:

local where = mobile.location(target)
npc.walk_to(serial, where.x, where.y, where.z, 1, true)

running only changes how the step looks: an NPC takes one step per on_think, two a second. Without z the place is the highest ground of the cell not above the NPC’s head, else the highest there. Start and goal must be within ultima.world.pathfinding_range tiles (38); farther is "no_path". A closed door that is not locked is opened on the way (the door’s on_npc_use, up to three tries), if the NPC may open doors; a locked one, or one the NPC may not open, blocks the way: it goes around, or walks up to it and then answers "no_path". Other mobiles do not block a path.

A vendor’s script may refuse one line of a purchase or of a sale with can_buy and can_sell, and hears the done line with on_bought and on_sold; the arguments and rules are in Script hooks, and the shop window in Vendors. A script that wants to know when an NPC is created, dies or is tamed, or when anything is crafted or carved, listens with events.on to mobile_created, mobile_deleted, mobile_died, pet_tamed, craft_succeeded and corpse_carved: they are listed with the other bus events.

Made with ❤️ by Tom

Buy me a coffee