Scripting API Reference
Reference for the Moongate v2 Lua scripting API.
definitions.luagenerated at startup is the source of truth for currently exported modules and signatures. This page contains legacy/planned examples too. Always validate signatures againstmoongate_data/scripts/definitions.lua.
If you are new to Moongate authoring, do not start here. Start with:
- Create Your First Content
- Create Your First Systems
- Create Your First Item Template
- Create Your First Item Script
- Create Your First NPC Brain
- Create Your First NPC Template
Current Runtime Baseline (Verified)
The following modules are currently wired in runtime:
ai_dialogueasync_jobbankbulletinclockcombatcommandconvertdialoguedicedoordyeeffectguardsgumphealinghelp_ticketsitemlocationlogmagicmapmobilenpc_stateperceptionpotion_effectsquestsrandomresurrectionscheduled_eventsspawnspeechsteeringtargettexttimetimerweather(set_global_light,clear_global_light)
Use the generated definitions.lua baseline at runtime for the complete exported surface. This page highlights the most
commonly used modules and examples, including the shared quest authoring and interaction flow.
Common shipped command scripts:
moongate_data/scripts/commands/gm/eclipse.luamoongate_data/scripts/commands/gm/set_world_light.luamoongate_data/scripts/commands/gm/teleports.lua
Common shipped helper scripts:
moongate_data/scripts/common/tick.luamoongate_data/scripts/common/dialogue.luamoongate_data/scripts/common/npc_dialogue.luamoongate_data/scripts/common/scheduled_events.lua
Real Script Examples
Helper Script: Tick
common.tick is a small utility for recurring brain cadences.
local tick = require("common.tick")
local cadence = tick.state({
move = 5000,
speech = 2000,
})
local now = time.now_ms()
tick.run(cadence, "move", now, function()
npc:move(random.direction())
end)
tick.run(cadence, "speech", now, function()
npc:say("Hello there.")
end)
The helper exposes:
tick.state(intervals, start_ms?)tick.ready(state, key, now_ms)tick.run(state, key, now_ms, action?)tick.reset(state, key, now_ms, interval_ms?)
World Speech And Emotes
Player world speech and NPC speech use the same runtime packet family.
local npc = mobile.get(serial)
if npc then
npc:say("Welcome, traveler.")
npc:emote("*growls softly*")
npc:yell("Intruder!")
npc:whisper("Keep your voice down.")
end
Runtime behavior:
- player
UnicodeSpeechis broadcast to nearby players in world range - incoming player text supports shorthand coercion:
*text*->Emote!text->Yell;text->Whisper
npc:emote(text)sendsChatMessageType.Emoteoverhead speechnpc:yell(text)sendsChatMessageType.Yelloverhead speechnpc:whisper(text)sendsChatMessageType.Whisperoverhead speech- current speech ranges are:
Whisper->1Regular/Emote->12Yell->18
- NPC brain speech listeners still receive
speech_type, so Lua can react differently to regular speech versus emotes
NPC Behavior Runtime Helpers
The current NPC behavior stack uses these runtime helpers directly from Lua:
combat.set_target(npc_serial, target_serial)
combat.clear_target(npc_serial)
steering.follow(npc_serial, target_serial, 1)
steering.evade(npc_serial, target_serial, 6)
steering.move_to(npc_serial, x, y, z, 1)
steering.stop(npc_serial)
perception.distance(npc_serial, target_serial)
perception.in_range(npc_serial, target_serial, 10)
perception.find_nearest_enemy(npc_serial, 10)
perception.find_nearest_player_enemy(npc_serial, 10)
npc_state.get_hp_percent(npc_serial)
npc_state.get_var(npc_serial, "follow_target_serial")
npc_state.set_var(npc_serial, "follow_target_serial", target_serial)
Use canonical AI runtime keys in new Lua code:
ai_actionai_target_serial
npc_state still accepts the legacy aliases modernuo_action and modernuo_target_serial for compatibility with older persisted NPC state. On read, the module migrates those values to the canonical keys and clears the legacy alias.
Enemy lookup is viewer-relative. The helper uses the same coarse AI relation model as Lua brain payloads:
FriendlyNeutralHostile
Faction hostility and same-faction friendliness therefore affect target acquisition automatically.
These helpers are the core building blocks behind current guard behaviors such as:
followranged_keep_distanceleashreturn_homehold_position
Authored Dialogue Helpers
The deterministic dialogue DSL ships as helper scripts, with runtime support provided by the dialogue module.
local dialogue = require("common.dialogue")
return dialogue.conversation("innkeeper", {
start = "start",
topics = {
room = { "room", "bed" }
},
topic_routes = {
room = "room_offer"
},
nodes = {
start = dialogue.node {
text = "Welcome.",
options = {
dialogue.option { text = "A room", goto_ = "room_offer" }
}
},
room_offer = dialogue.node {
text = "A room costs 15 gold.",
options = {}
}
}
})
Runtime notes:
goto_is normalized togoto- option numbers are selected by nearby player speech like
1,2,3 - authored dialogue is meant to run before
ai_dialoguefallback when both are configured
Persistent dialogue memory is stored per npc <-> mobile under:
moongate_data/runtime/dialogue_memory/<npc_serial>.json
Available context helpers include:
ctx:get_memory_flag(key)ctx:set_memory_flag(key, value)ctx:get_memory_number(key)ctx:set_memory_number(key, value)ctx:add_memory_number(key, delta)ctx:get_memory_text(key)ctx:set_memory_text(key, value)
Scheduled Events
The scheduled event DSL ships as a helper script with runtime support from the scheduled_events module.
local scheduled_events = require("common.scheduled_events")
return scheduled_events.event("town_crier_morning", {
trigger_name = "town_crier_announcement",
recurrence = "weekly",
time = "09:00",
time_zone = "Europe/Rome",
days_of_week = { "monday", "wednesday" },
payload = {
message = "Hear ye!"
}
})
Supported recurrence values:
oncedailyweeklymonthly
Key fields:
enabledtrigger_namerecurrencetimetime_zonestart_atdays_of_weekday_of_monthpayload
When a scheduled event fires, the global script bridge invokes:
function on_scheduled_event(event)
if event.trigger_name == "town_crier_announcement" then
log.info("Scheduled event fired: " .. event.event_id)
end
end
Current payload fields exposed through the event object:
event.event_idevent.trigger_nameevent.scheduled_at_utcevent.fired_at_utcevent.recurrence_typeevent.payload
Help Ticketing
The in-game help flow is still entered through 0x9B and Lua on_help_request(...), but the default gump now drives a ticket wizard:
- category selection
- free-text entry
- ticket persistence
TicketOpenedEventpublication
Lua submit helpers are exposed through help_tickets:
local help_tickets = require("help_tickets")
help_tickets.submit(session_id, "Question", "I am stuck near Britain bank.")
Supported category names:
QuestionStuckBugAccountSuggestionOtherVerbalHarassmentPhysicalHarassment
Persisted tickets store:
- sender account and character ids
- category
- message
- map id and location
- status (
Open,Assigned,Closed) - timestamps for creation, assignment, closing, and last update
For staff-facing triage, dashboard usage, and endpoint mapping, see Help Ticket Workflow.
Quest Authoring And Runtime
Quest authoring is Lua-first, but runtime execution stays in validated C# quest services.
Authored quest scripts live under:
moongate_data/scripts/quests/**/*.lua
Example:
quest.define({
id = "new_haven.rat_hunt",
name = "Rat Hunt",
category = "starter",
description = "Cull the rat infestation near the mill.",
quest_givers = { "farmer_npc" },
completion_npcs = { "farmer_npc" },
repeatable = false,
max_active_per_character = 1,
objectives = {
quest.kill({ mobiles = { "sewer_rat", "giant_rat" }, amount = 10 }),
quest.collect({ item_template_id = "rat_tail", amount = 10 }),
quest.deliver({ item_template_id = "rat_tail", amount = 10 })
},
rewards = {
quest.gold(150),
quest.item("bandage", 10)
}
})
Shared runtime helpers exposed to Lua gumps and interaction scripts:
quests.open(session_id, character_id, npc_serial)
quests.open_journal(session_id, character_id)
quests.get_available(session_id, character_id, npc_serial)
quests.get_active(session_id, character_id, npc_serial)
quests.get_journal(session_id, character_id)
quests.accept(session_id, character_id, npc_serial, quest_id)
quests.complete(session_id, character_id, npc_serial, quest_id)
Runtime notes:
- the NPC
Questinteraction opens the shared dialog ingumps/quests/quest_dialog.lua - the client
Questsbutton opens the shared journal ingumps/quests/quest_journal.lua - quest file hot reload goes through validation and preserves the last valid in-memory state when an edit fails
max_active_per_characteris currently validated to1in v1
For a focused authoring guide, see Quests.
Loot Containers
Loot containers are data-driven through item templates and loot templates rather than a dedicated Lua module.
- container item templates declare
lootTables - loot templates define the weighted entries that can be generated
- first-open generation and optional refill behavior are handled by the server when the container is opened
For first-open generation, refillable containers, validation caveats, and real template examples, see Loot Containers.
Item Script: Apple
items_apple = {
on_double_click = function(ctx)
local ref = item.get(ctx.item.serial)
if ref then
ref:delete()
end
speech.send(ctx.session_id, "You eat the apple.")
end
}
Item Script: Door
items_door = {
on_double_click = function(ctx)
local toggled = door.toggle(ctx.item.serial)
local ref = item.get(ctx.item.serial)
if ref then
if toggled then
ref:play_sound(0xEA)
else
ref:play_sound(0xEC)
end
end
end
}
Item Script: Dye Tub
items_dye_tub = {
on_double_click = function(ctx)
return dye.begin(ctx.session_id, ctx.item.serial, function(target_serial)
return target_serial ~= nil and target_serial ~= 0
end)
end
}
The dye module currently exposes:
dye.begin(session_id, dye_tub_serial, callback?)- starts the classic UO target cursor flow
- the optional callback receives the selected
target_serial - returning
falserejects the target before the hue picker opens
dye.send_dyeable(session_id, item_serial, model?)- opens the hue picker directly for a known dyeable item
Runtime notes:
- target items must currently be accessible from the player inventory/equipment
- the final hue application, persistence, and item refresh are handled by
IDyeColorService - item templates opt-in through the
dyeableflag, which is materialized into runtime item metadata
Item Script: Bulletin Board
items_bulletin_board = {
on_double_click = function(ctx)
return bulletin.open(ctx.session_id, ctx.item.serial)
end
}
The bulletin module currently exposes:
bulletin.open(session_id, board_serial)- opens the classic bulletin board UI for the specified board item
- the server then sends message summaries over packet
0x71
Runtime notes:
- the board protocol uses the item serial as
BoardId - posting and reply creation are persisted through the bulletin-board message repository
- delete is currently owner-only and rejected when the target message already has replies
Item Script: Teleporter
items_teleport = {
on_double_click = function(ctx)
local meta = ctx.metadata or {}
local dest_map = meta.dest_map
local dest = convert.parse_point3d(meta.dest_x, meta.dest_y, meta.dest_z)
local delay = convert.parse_delay_ms(meta.delay or "0")
local mob = mobile.get(ctx.mobile_id)
if not mob then return end
local map_id = map.to_id(dest_map or mob.map_id)
effect.send_to_player(ctx.mobile_id, mob.location_x, mob.location_y, mob.location_z,
0x3728, 10, 10, 0, 0, 2023)
mob:play_sound(0x1FE)
if delay > 0 then
timer.after(delay, function()
mob:teleport(map_id, dest.x, dest.y, dest.z)
end)
else
mob:teleport(map_id, dest.x, dest.y, dest.z)
end
end
}
Read-Only Book Template
Item templates can point to a book text file with bookId. The file lives under
moongate_data/templates/books/<book_id>.txt, supports the same # comments and
Scriban placeholders used by text templates, and is rendered once when the item
is created.
Example item template:
{
"type": "item",
"id": "moongate_welcome_book",
"name": "Welcome To Moongate",
"category": "Books",
"itemId": "0x0FF0",
"scriptId": "none",
"bookId": "welcome_player",
"isMovable": true,
"tags": ["book", "moongate"]
}
Example book file:
[Title] Welcome To {{ shard.name }}
[Author] The Moongate Team
[ReadOnly] True
Welcome traveler.
Website: {{ shard.website_url }}
At runtime the rendered title, author, and content are stored into the item
custom params (book_title, book_author, book_content). Double-click opens
the classic client book UI in read-only mode. The server also listens to client
0x66 page requests and serves the rendered book content page-by-page.
Writable books use the same classic client UI, but the save flow differs:
book_writable = truemarks the item as writable at runtime0xD4savestitleandauthor0x66saves page content- writes are accepted only when the book is equipped by the player or inside the player's backpack tree
Current writable storage still uses the item custom params:
book_titlebook_authorbook_contentbook_writable
Book templates can also declare writability directly in the .txt file:
[ReadOnly] True-> forces the resulting item to be read-only[ReadOnly] False-> forces the resulting item to be writable- if
[ReadOnly]is absent, Moongate falls back to item/startupwritable
When present, [ReadOnly] takes precedence over fallback writable metadata.
Lootable Container Template
Container item templates can also declare weighted loot generation with lootTables.
This is meant for chests and similar containers that should populate their contents
from moongate_data/templates/loot/*.json.
Example chest template:
{
"type": "item",
"id": "loot_test_chest",
"base_item": "metal_chest",
"name": "Loot Test Chest",
"category": "Test Containers",
"itemId": "0x0E40",
"scriptId": "items.loot_test_chest",
"isMovable": true,
"containerLayoutId": "metal_chest",
"lootTables": ["loot_test_chest_basic"],
"params": {
"loot_refillable": {
"type": "string",
"value": "true"
},
"loot_refill_seconds": {
"type": "string",
"value": "300"
}
},
"tags": ["container", "loot"]
}
Example loot table:
{
"type": "loot",
"id": "loot_test_chest_basic",
"name": "Loot Test Chest Basic",
"category": "Test",
"rolls": 2,
"noDropWeight": 0,
"entries": [
{ "itemTemplateId": "gold", "weight": 5, "amount": 125 },
{ "itemTag": "resources", "weight": 3, "amount": 25 },
{ "itemTemplateId": "lockpick", "weight": 2, "amount": 5 }
]
}
Entry rules:
- each entry may define exactly one of:
itemTemplateIditemIditemTag
rollsdefaults to1itemTagresolves a random item template that already carries that tag
Runtime behavior:
lootTablesare rolled the first time the container is opened- each loot table can perform multiple weighted rolls via
rolls - generated contents are then persisted like any normal container contents
- if
loot_refillable = true, the container can refill later - refill is lazy, not timer-driven:
the container is marked ready only after it becomes empty, and loot is regenerated on a later open once
loot_refill_secondshas elapsed - if the container still has items inside, no refill happens
NPC mobile templates can also declare lootTables. When an NPC dies:
- equipped items are moved to the corpse
- backpack contents are moved to the corpse
- configured
lootTablesare generated immediately on the corpse in additive mode
Example mobile snippet:
{
"type": "mobile",
"id": "zombie_npc",
"lootTables": ["undead.low"]
}
Runtime custom metadata used internally:
item_template_idloot_generatedloot_refill_ready_at_utc
GM Command: Eclipse
command.register("eclipse", function(ctx)
weather.set_global_light(26)
speech.broadcast("The world goes dark...")
end, { gm = true })
NPC Brain: Orion (Cat)
local MOVE_INTERVAL = 1000
local SPEECH_INTERVAL = 2000
local SOUND_INTERVAL = 3000
local messages = {
"Meow!", "Purrrr...", "Mrrrow!", "*rubs against your leg*"
}
orion = {}
function orion.on_think(npc_id)
local mob = mobile.get(npc_id)
local last_move, last_speech, last_sound = 0, 0, 0
while true do
local now = time.now_ms()
if mob and mob:is_alive() then
if now - last_move >= MOVE_INTERVAL then
mob:wander(3)
last_move = now
end
if now - last_speech >= SPEECH_INTERVAL then
local msg = messages[random.int(1, #messages)]
mob:say(msg)
last_speech = now
end
if now - last_sound >= SOUND_INTERVAL then
mob:play_sound(0xDB)
last_sound = now
end
end
coroutine.yield(250)
end
end
function orion.on_event(event_type, from_serial, event_obj)
if event_type == "speech_heard" and event_obj then
local text = string.lower(event_obj.text or "")
if string.find(text, "hello", 1, true) then
local mob = mobile.get(event_obj.listener_npc_id)
if mob then mob:say("Meow!") end
end
end
end
Runtime Notes
Current runtime includes visual effect APIs:
-- global module
effect.send(mapId, x, y, z, itemId, speed, duration, hue, renderMode, effect, explodeEffect, explodeSound, layer, unknown3)
effect.send_to_player(characterId, x, y, z, itemId, speed, duration, hue, renderMode, effect, explodeEffect, explodeSound, layer, unknown3)
-- mobile proxy
mobile.get(serial):SetEffect(itemId, speed, duration, hue, renderMode, effect, explodeEffect, explodeSound, layer, unknown3)
Mobile Template Skill Materialization
MobileTemplateDefinition.skills is now materialized into the persisted mobile
entity at creation time.
Template shape:
{
"id": "mage_apprentice",
"type": "mobile",
"skills": {
"magery": 750,
"meditation": 500,
"wrestling": 300
}
}
Behavior:
- newly created mobiles get a full skill table seeded from
SkillInfo.Table - unspecified skills default to
0 - specified template skills override those defaults
- character creation also creates the full skill table and maps the four starting skills into persisted mobile skills
Current persisted skill entry fields are:
valuebasecaplock
The runtime 0x3A skill list packet reads directly from this persisted mobile skill table.
Global Modules
log - Logging Module
log.debug(message: string) -- Debug level logging
log.info(message: string) -- Info level logging
log.warning(message: string) -- Warning level logging
log.error(message: string) -- Error level logging
log.critical(message: string) -- Critical level logging
Example:
log.debug("Debug information for developers")
log.info("Server started successfully")
log.warning("Low memory warning")
log.error("Failed to load template")
log.critical("Database connection lost")
server - Server Module
server.broadcast(message: string) -- Broadcast to all players
server.get_player_count(): number -- Get active player count
server.get_player(serial: number): Player|nil -- Get player by serial
server.get_players(): table -- Get all players
server.shutdown() -- Graceful shutdown
server.save_world() -- Save world state
server.get_uptime(): number -- Get server uptime in seconds
server.get_version(): string -- Get server version
Example:
-- Broadcast message
server.broadcast("Server will restart in 5 minutes!")
-- Get player count
local count = server.get_player_count()
log.info("Players online: " .. count)
-- Get specific player
local player = server.get_player(0x00000001)
if player then
log.info("Found player: " .. player.Name)
end
game - Game Module
game.spawn_mobile(body: number, hue: number, x: number, y: number, z: number, map: number): number
game.spawn_item(itemId: number, amount: number, x: number, y: number, z: number): number
game.spawn_npc(npcId: string, x: number, y: number, z: number): number
game.get_mobile(serial: number): Mobile|nil -- Get mobile by serial
game.get_item(serial: number): Item|nil -- Get item by serial
game.move_object(serial: number, x: number, y: number, z: number): boolean
game.delete_object(serial: number): boolean -- Delete object
game.get_distance(obj1: number, obj2: number): number
game.get_objects_in_range(x: number, y: number, z: number, range: number): table
Example:
-- Spawn a mobile
local mobileSerial = game.spawn_mobile(0x0190, 0, 1000, 2000, 0, 0)
-- Spawn an item
local itemSerial = game.spawn_item(0x0E76, 1, 1000, 2000, 0)
-- Move object
local success = game.move_object(mobileSerial, 1001, 2000, 0)
-- Get distance
local distance = game.get_distance(playerSerial, targetSerial)
if distance > 10 then
log.warning("Target too far away")
end
player - Player Module
player.send_message(serial: number, text: string) -- Send message to player
player.send_gump(serial: number, gumpId: number, data: table) -- Send gump dialog
player.teleport(serial: number, x: number, y: number, z: number, map: number)
player.add_item(serial: number, itemId: number, amount: number): number
player.remove_item(serial: number, itemSerial: number, amount: number): boolean
player.get_skill(serial: number, skillName: string): number
player.set_skill(serial: number, skillName: string, value: number)
player.get_stats(serial: number): table -- Get str/dex/int
player.set_stats(serial: number, stats: table) -- Set str/dex/int
player.send_sound(serial: number, soundId: number) -- Play sound
player.send_effect(serial: number, effectId: number, target: number)
Example:
-- Send message
player.send_message(playerSerial, "Welcome to the server!")
-- Teleport player
player.teleport(playerSerial, 5000, 1000, 0, 0)
-- Add item to backpack
local backpackItem = player.add_item(playerSerial, 0x0E76, 1)
-- Get/Set skills
local magery = player.get_skill(playerSerial, "magery")
player.set_skill(playerSerial, "magery", 100.0)
-- Get stats
local stats = player.get_stats(playerSerial)
log.info("STR: " .. stats.Strength .. ", DEX: " .. stats.Dexterity .. ", INT: " .. stats.Intelligence)
world - World Module
world.get_time(): table -- Get server time {year, month, day, hour, minute, second}
world.get_tile(x: number, y: number, z: number, map: number): table
world.get_region(x: number, y: number, map: number): string
world.get_weather(): table -- Get current weather
world.set_weather(weatherType: number, duration: number)
world.spawn_npc(npcId: string, x: number, y: number, z: number, map: number): number
world.despawn(serial: number): boolean
world.is_day(): boolean -- Check if it's daytime
world.get_players_in_region(region: string): table
Example:
-- Get time
local time = world.get_time()
log.info(string.format("Time: %02d:%02d:%02d", time.hour, time.minute, time.second))
-- Get tile info
local tile = world.get_tile(1000, 2000, 0, 0)
log.info("Tile ID: " .. tile.Id .. ", Z: " .. tile.Z)
-- Get region
local region = world.get_region(1000, 2000, 0)
log.info("Region: " .. region)
-- Set weather
world.set_weather(2, 300) -- Rain for 5 minutes
commands - Commands Module
commands.register(name: string, handler: function) -- Register chat command
commands.unregister(name: string) -- Unregister command
commands.process(playerSerial: number, text: string): boolean, any
commands.list(): table -- List all commands
Example:
-- Register command
commands.register("teleport", function(playerSerial, args)
local player = server.get_player(playerSerial)
if not player.IsAdmin then
return false, "Access denied"
end
local x, y, z = args:match("(%d+) (%d+) (%d+)")
player.teleport(playerSerial, tonumber(x), tonumber(y), tonumber(z))
return true
end)
-- Unregister command
commands.unregister("teleport")
-- List commands
local cmds = commands.list()
for _, cmd in ipairs(cmds) do
log.info("Command: " .. cmd)
end
Data Types
Player
Player = {
Serial: number, -- Unique identifier
Name: string, -- Character name
Account: string, -- Account username
Position: Point3D, -- Current position
IsAdmin: boolean, -- Admin flag
IsModerator: boolean, -- Moderator flag
IsInWorld: boolean, -- In world flag
LastActivity: number -- Last activity timestamp
}
Mobile
Mobile = {
Serial: number, -- Unique identifier
Name: string, -- Mobile name
Body: number, -- Body ID
Hue: number, -- Hue color
Position: Point3D, -- Current position
Map: number, -- Map facet
Hits: number, -- Current hits
HitsMax: number, -- Maximum hits
Stamina: number, -- Current stamina
StaminaMax: number, -- Maximum stamina
Mana: number, -- Current mana
ManaMax: number, -- Maximum mana
Direction: number, -- Facing direction
WarMode: boolean, -- War mode flag
Paralyzed: boolean, -- Paralyzed flag
Poisoned: boolean -- Poisoned flag
}
Item
Item = {
Serial: number, -- Unique identifier
ItemId: number, -- Item graphic ID
Amount: number, -- Stack amount
Hue: number, -- Hue color
Position: Point3D, -- Position (if world item)
ParentSerial: number|nil, -- Parent container serial
Layer: number|nil, -- Equip layer (if equipped)
IsMovable: boolean, -- Can be picked up
IsContainer: boolean -- Is a container
}
Point3D
Point3D = {
X: number, -- X coordinate
Y: number, -- Y coordinate
Z: number -- Z coordinate
}
Map
Map = {
Felucca = 0, -- Felucca facet
Trammel = 1, -- Trammel facet
Ilshenar = 2, -- Ilshenar facet
Malas = 3, -- Malas facet
Tokuno = 4, -- Tokuno facet
TerMur = 5 -- TerMur facet
}
Callbacks
Moongate currently exposes two different callback styles:
- global script callbacks such as
on_player_connected(...),on_aggressive_action(...), andon_ticket_opened(...) - NPC brain callbacks such as
on_event(...),on_speech(...),on_death(...)
These are not the same thing:
- global callbacks come from the game event bus through
GameEventScriptBridgeService - brain callbacks are dispatched to one NPC brain instance at a time
Server Callbacks
function on_server_start() -- Called when server starts
function on_server_stop() -- Called when server stops
function on_tick() -- Called every game tick
function on_save_world() -- Called before world save
Player Callbacks
function on_player_connected(player) -- Player connected
function on_player_disconnected(player) -- Player disconnected
function on_player_speech(player, text) -- Player spoke
function on_player_login(player) -- Player logged in
function on_player_logout(player) -- Player logged out
function on_player_use_item(player, item) -- Player used item
function on_player_equip_item(player, item) -- Player equipped item
function on_player_combat_hit(attacker, defender, damage) -- Combat hit
World Callbacks
function on_mobile_created(mobile) -- Mobile created
function on_mobile_deleted(mobile) -- Mobile deleted
function on_item_created(item) -- Item created
function on_item_deleted(item) -- Item deleted
function on_weather_changed(weather) -- Weather changed
Global Game Event Bridge
Some gameplay events are forwarded automatically to Lua global callbacks by naming convention:
function on_aggressive_action(event)
log.info(
"Aggressive action: attacker={0} defender={1}",
tostring(event.attacker_id),
tostring(event.defender_id)
)
end
Current behavior:
- event type
AggressiveActionEventmaps to Lua callbackon_aggressive_action - this callback is global script-side, not an NPC brain hook
- it is intended for shard-level reactions such as:
- guarded region checks
- no-combat zone logic
- analytics or logging
Current payload fields for on_aggressive_action(event):
attacker_iddefender_idmap_idlocationattackerdefender
Help tickets also enter the global bridge:
function on_ticket_opened(event)
log.info(
"Ticket opened: id={0} category={1} sender={2}",
tostring(event.ticket_id),
tostring(event.category),
tostring(event.sender_character_id)
)
end
Current behavior:
- event type
TicketOpenedEventmaps to Lua callbackon_ticket_opened - this callback is global script-side, not an NPC brain hook
- it is intended for shard-level reactions such as:
- staff alerting
- Discord/webhook bridges
- analytics or audit trails
Current payload fields for on_ticket_opened(event):
ticket_idsender_character_idsender_account_idcategorymessagemap_idlocationcreated_at_utc
Item Script Dispatcher API
Item templates/entities can define a scriptId. The server can dispatch item hooks through IItemScriptDispatcher.
Dispatch Convention
<script_id_normalized>.<hook_name>(ctx)
Example:
-- scriptId: "items.healing-potion" => table "items_healing_potion"
items_healing_potion = {
on_click = function(ctx)
log.info("Item hook called for " .. tostring(ctx.item.serial))
end,
on_double_click = function(ctx)
log.info("Double click from session " .. tostring(ctx.session_id))
end
}
Fallback naming when scriptId == "none":
-- item name "Brick" -> "brick" (first) then "items_brick" (second)
brick = {
on_double_click = function(ctx)
log.info("Brick used by session " .. tostring(ctx.session_id))
end
}
Hook aliases:
single_click->on_click,OnClick,on_single_click,OnSingleClickdouble_click->on_double_click,OnDoubleClick
Mobile Helper APIs For Item Scripts
Item scripts can use the mobile module to bridge skill checks and inventory operations without moving item behavior into C#:
local mob = mobile.get(character_id)
local weapon = mobile.get_weapon(character_id)
local backpack = mobile.get_backpack(character_id)
local displayed = mobile.get_skill(character_id, "archery")
local success = mobile.check_skill(character_id, "archery", -25.0, 25.0, target_serial)
local consumed = mobile.consume_item(character_id, 0x0F3F, 1)
local added = mobile.add_item_to_backpack(character_id, "arrow", 5)
Available helpers:
mobile.get(serial)-> returns a mobile proxy ornilmobile.get_skill(serial, skill_name)-> displayed base skill value asnumbermobile.check_skill(serial, skill_name, min_skill, max_skill, target_serial?)-> runs a skill check and applies normal gain rulesmobile.get_weapon(serial)-> equipped weapon proxy ornilmobile.get_backpack(serial)-> backpack proxy ornilmobile.consume_item(serial, item_id, amount?)-> consumes from quivers first, then backpackmobile.add_item_to_backpack(serial, template_id, amount?)-> spawns a template item and moves it into the backpack
These helpers are used by training-style item scripts such as melee dummies, archery buttes, and pickpocket dips.
Text API
text.render(...) loads a Scriban template from moongate_data/scripts/texts/** and returns the rendered string.
local body = text.render("welcome_player.txt", {
player = {
name = "Tommy",
account_type = "Regular",
character_id = 2
}
})
Available built-in template values:
shard.nameshard.website_url
Template comment rules:
- a line starting with
#after trim is ignored - inline
#starts a comment and truncates the rest of the line - use
\#to keep a literal#
Example moongate_data/scripts/texts/welcome_player.txt:
# internal comment
Welcome to {{ shard.name }}, {{ player.name }}.
Website: {{ shard.website_url }} # shown to the player
Gump API
local builder = gump.create()
builder:resize_pic(0, 0, 9200, 280, 150)
builder:text(20, 20, 1152, "First gump")
builder:button(20, 95, 4005, 4007, 1)
gump.send(session_id, builder, character_id, 0xB10C, 120, 80)
gump.on(0xB10C, 1, function(ctx)
local second = gump.create()
second:resize_pic(0, 0, 9200, 260, 120)
second:text(20, 20, 1152, "Second gump")
gump.send(ctx.session_id, second, ctx.character_id or 0, 0xB10D, 140, 90)
end)
gump.on(...) callback ctx fields:
session_idcharacter_idgump_idbutton_idserialswitchestext_entries
File-based layout (recommended):
-- moongate_data/scripts/gumps/test_shop.lua
return {
ui = {
{ type = "page", index = 0 },
{ type = "background", x = 0, y = 0, gump_id = 9200, width = 320, height = 180 },
{ type = "label", x = 20, y = 20, hue = 1152, text = "Hello $ctx.name" },
{ type = "button", id = 1, x = 20, y = 130, normal_id = 4005, pressed_id = 4007, onclick = "open_next" }
},
handlers = {
open_next = function(cb_ctx)
log.info("Clicked button " .. tostring(cb_ctx.button_id))
end
}
}
local layout = require("gumps/test_shop")
local ui_ctx = { name = "Orion", level = 42 }
gump.send_layout(session_id, layout, character_id, 0xB300, 120, 80, ui_ctx)
Using text.render(...) with htmlgump:
local body = text.render("welcome_player.txt", {
player = {
name = "Tommy"
}
}) or "Welcome."
local g = gump.create()
g:resize_pic(0, 0, 9200, 420, 240)
g:html(20, 20, 380, 180, body, true, true)
gump.send(session_id, g, character_id, 0xB500, 120, 80)
Supported file-based element types currently include:
page,groupbackground,alpha_regionimage,image_tiled,itemlabel,label_cropped,htmlcheckbox,radiotext_entry,text_entry_limitedtooltipbutton,button_page
async_job Module
Named background jobs callable from Lua:
async_job.run("echo", "req-1", { text = "hello" })
async_job.try_run("echo", "npc:1", "req-2", { text = "world" })
Global completion callbacks:
function on_async_job_result(job_name, request_id, result)
end
function on_async_job_error(job_name, request_id, message)
end
Payload and result values must be plain Lua data:
nilbooleannumberstring- nested tables / arrays of the same
ItemScriptContext (ctx payload)
ItemScriptContext = {
hook: string,
session_id: number|nil,
mobile_id: number|nil,
metadata: table<string, any>|nil,
item: {
serial: number,
script_id: string|nil,
name: string|nil,
map_id: number,
item_id: number,
amount: number,
hue: number,
location: { x: number, y: number, z: number }
}
}
Utility Functions
String Utilities
string.split(str: string, delimiter: string): table
string.trim(str: string): string
string.starts_with(str: string, prefix: string): boolean
string.ends_with(str: string, suffix: string): boolean
string.contains(str: string, substr: string): boolean
Table Utilities
table.contains(tbl: table, value: any): boolean
table.keys(tbl: table): table
table.values(tbl: table): table
table.length(tbl: table): number
table.merge(tbl1: table, tbl2: table): table
Math Utilities
math.distance(x1: number, y1: number, x2: number, y2: number): number
math.clamp(value: number, min: number, max: number): number
math.lerp(a: number, b: number, t: number): number
math.random_range(min: number, max: number): number
Error Handling
pcall for Safe Calls
local success, result = pcall(function()
return game.spawn_mobile(0x0190, 0, 1000, 2000, 0)
end)
if not success then
log.error("Failed to spawn mobile: " .. tostring(result))
else
log.info("Spawned mobile with serial: " .. result)
end
xpcall with Error Handler
local function error_handler(err)
log.error("Script error: " .. tostring(err))
return err
end
local success, result = xpcall(function()
risky_operation()
end, error_handler)
Best Practices
Performance
-- GOOD: Cache function references
local log_info = log.info
local game_spawn = game.spawn_mobile
for i = 1, 10 do
log_info("Spawning mobile " .. i)
game_spawn(0x0190, 0, 1000 + i, 2000, 0)
end
-- BAD: Repeated lookups
for i = 1, 10 do
log.info("Spawning mobile " .. i)
game.spawn_mobile(0x0190, 0, 1000 + i, 2000, 0)
end
Memory Management
-- GOOD: Clear tables when done
local large_table = {}
for i = 1, 10000 do
large_table[i] = {data = i}
end
-- Process table
process_data(large_table)
-- Clear for GC
large_table = nil
-- BAD: Memory leak
local persistent_table = {}
function on_tick()
persistent_table[#persistent_table + 1] = {tick = world.get_time()}
end
Next Steps
- Modules - Create custom modules
- Overview - Scripting introduction
- Persistence - Data storage
Previous: Modules | Next: Persistence Overview