Repository navigation
CustomMonsters Scripts
Flags and a body colour get you a uniformly tinted model. A script is what gives a monster lit skin, a glowing crystal, beams that fade in as it attacks, and effects of its own.
Data\Custom\Scripts\Game\Monsters\<monsterid>.gsc production
Data\Custom\Scripts\Game\Monsters\<monsterid>.lua dev mode only
The example throughout is Monsters\2498.lua, the Hydra.
The chunk runs once when the script is loaded, and hands over closures:
local Enums = require("Enums")
local Helpers = require("Helpers")
local R = require("Registry")
Monster.OnCreate(function(o, m) ... end)
Monster.OnRender(function(o, m) ... end)Every handler receives two arguments:
-
o- the object: position, angle, meshes, alpha. Shared with map scripts, and documented in Objects and the sandbox. -
m- the monster record: index, attack state, body light, render flags.
| Handler | When |
|---|---|
Monster.OnCreate |
once, when the monster is set up |
Monster.OnRender |
every frame, before the body is drawn |
Monster.OnRenderPost |
every frame, right after the body is drawn |
Monster.OnAttack |
every frame while an attack is running |
OnCreate runs straight after the config is applied, so a script can override it.
Two things are not available that early, because the model is not resolved yet:
m.bodyLight* and m.renderFlags / m.bright. Set those in OnRender.
For anything that should hold for the whole life of the instance - meshes, scale, bounding box - instead of rewriting it every frame.
Monster.OnCreate(function(o, m)
o.boundingBoxMaxX = 100.0
o.boundingBoxMaxY = 100.0
o.boundingBoxMaxZ = 150.0
-- Both of these, or the head beams are permanently on.
o.blendMesh = 5
o.blendMeshLight = 0.0
o.renderShadow = false
o.enableShadow = false
m.hideShadow = true
end)The bounding box decides how far away the monster can be clicked and when it is culled. A custom model without one is awkward to select, which is why it is the first thing Hydra sets.
The three shadow switches matter for any model with a blend mesh: the shadow pass draws that mesh too, so glowing beams end up as black bars on the floor.
Every drawn frame, before the body. This is where per-frame body state goes.
Monster.OnRender(function(o, m)
-- Ramp the blend mesh brightness with the attack: fades the head beams in when
-- the attack starts and back out when it ends.
local action = o.currentAction
local attacking = action >= Enums.Action.Attack1 and action <= Enums.Action.Attack2
local light = o.blendMeshLight + (attacking and 0.1 or -0.1)
if light > 1.0 then light = 1.0 end
if light < 0.0 then light = 0.0 end
o.blendMeshLight = light
-- Two lights on one bone: transform once, spawn twice.
local x, y, z = o:TransformPosition(63, 0, 0, 20.0)
-- Our own copies of the client's pictures, from Registry.lua.
local lightning, shiny = Helpers.Texture(R, "lightning2"), Helpers.Texture(R, "shiny03")
if x and lightning and shiny then
Helpers.RenderLightAt(o, lightning, 1.0, x, y, z)
Helpers.RenderLightAt(o, shiny, 4.0, x, y, z)
end
end)o:TransformPosition(bone, dx, dy, dz) turns a model-local offset into a world
position through one of the model's bones - how you pin a flame to a torch or a glow
to an eye. An out-of-range bone returns nothing, so check before using the result.
This is the handler that makes a ported monster look right. The extra pass draws some of the model's meshes again, on top of the finished body, with different flags and often a different texture. A lit skin or a glowing crystal comes from here, and a monster built with flags alone looks flat because it is missing it.
Issued from OnRender the extra pass would land underneath the body, so it has to
be this handler.
Monster.OnRenderPost(function(o, m)
local light = math.sin(Map.WorldTime() * 0.002) * 0.3 + 0.5
local scroll = math.floor(Map.WorldTime()) % 100 * 0.01
m:BeginRender(o.alpha)
m:RenderMesh(0, Enums.RenderFlags.TEXTURE, o.alpha, 0, light,
o.blendMeshTexCoordU, o.blendMeshTexCoordV, m:TextureIndex(6))
m:RenderMesh(3, Enums.RenderFlags.CHROME + Enums.RenderFlags.BRIGHT, o.alpha, 3, light,
o.blendMeshTexCoordU, -scroll)
m:EndRender()
end)| Call | Purpose |
|---|---|
m:BeginRender(alpha) |
opens the pass |
m:RenderMesh(mesh, flags, alpha, blendMesh, blendMeshLight, u, v, texture) |
draws one mesh again |
m:EndRender() |
closes it |
m:TextureIndex(slot) |
the model's own texture id for that slot |
Everything past mesh and flags is optional: alpha 1, blendMesh -1,
blendMeshLight 1, u/v 0, texture -1 (keep the mesh's own). An out-of-range
mesh or texture slot is dropped rather than passed on.
m.renderFlags and m.bright do not exist here - the body is already drawn.
Runs every frame while the attack lasts, not once. That is deliberate: it lets an effect animate across the attack, but it means a one-shot effect has to be gated.
m.attackTime counts up from 1, so == 1 is the first frame of the attack. An
attack can end long before it counts far - every one of the Pharaoh's, chasing its
target, ended after its first frame - so for a one-shot effect == 1 is the only frame
that always comes.
Monster.OnAttack(function(o, m)
-- Every fifth tick: a laser from the head bone.
if m.attackTime % 5 == 1 then
local x, y, z = o:TransformPosition(63, 0, 0, 0)
if x then
Laser(x, y, z, o.angleX, o.angleY, o.angleZ, LASER_ORANGE)
end
end
-- Once, on the first tick of a boss skill: a nine-way fan.
if m.skill == Enums.Skill.Boss and m.attackTime == 1 then
local px, py, pz = o:GetPosition()
local az = o.angleZ + 20.0
for i = 1, 9 do
az = az + 40.0
Laser(px, py, pz + 50.0, o.angleX, o.angleY, az, LASER_BLUE)
end
end
end)Laser is the Hydra script's own: it only notes the laser, and OnRender draws every
live one for 20 game steps as a row of Effect\Spark03.jpg sprites. The client's Hydra
laser is nothing more than that picture, so the script draws it from its own copy - see
2498.lua.
m.skill is the skill id being used, so effects can branch on it.
Returning a monster index from OnAttack runs that monster's built-in attack
effect instead, skill checks and all:
Monster.OnAttack(function(o, m)
if m.skill == 42 then
return 89 -- use what the game does for monster 89
end
end)Returning nothing leaves your own id in place, which means no built-in effect at all
- exactly what you want when the script draws its own.
A player skill on a monster - how it gets one, which half the client draws and which
SkillEffects.lua adds, m:SpawnSkillEffect, m:TargetPosition and m:ShootArrow - has a
page of its own: Monster skills.
Everything on m:
| Property | Access | Meaning | Example |
|---|---|---|---|
index |
read | the monster's id, as in CustomMonster.xml and MonsterList.xml
|
2498 |
attackTime |
read |
0 outside an attack; during one it counts up from 1, to 15 at most |
if m.attackTime == 1 then |
skill |
read | the skill number of the current attack - MonsterList AttackType 100 + skill; 0 on a plain attack |
2023 - Raining Arrow |
boneCount |
read | bones in the model; bones are 0..boneCount - 1
|
if bone < m.boneCount then |
meshCount |
read | meshes in the model; meshes are 0..meshCount - 1
|
for mesh = 0, m.meshCount - 1 do |
actionCount |
read | animations in the model; actions are 0..actionCount - 1
|
if m.actionCount > Enums.Action.Attack3 then |
nameHeight |
read/write | how high the name plate and the HP bar sit over the model, in world units - the Ypose of its npcName.txt row |
m.nameHeight = 400 |
nameMark |
read/write | the MarkValue of its npcName.txt row, 0 or 1 - only the Chaos Goblins use 1
|
m.nameMark = 0 |
hideShadow |
read/write |
true draws no shadow under the monster |
m.hideShadow = true |
bodyLightR/G/B |
read/write | body colour, 0..1 each - shared by every instance of the model, see below |
m.bodyLightR = 1.0 |
renderFlags |
read/write | the body pass mask from Enums.RenderFlags, OnRender only |
m.renderFlags = Enums.RenderFlags.METAL + Enums.RenderFlags.BRIGHT |
bright |
read/write | brightness of the body pass - what <Glow Power> sets; OnRender only |
m.bright = 1.0 |
| Method | What it does | Defaults |
|---|---|---|
m:SetBodyLight(r, g, b) |
sets bodyLightR/G/B at once |
a missing value is 0 |
m:BeginRender(alpha), m:RenderMesh(...), m:EndRender()
|
the second pass - see Monster.OnRenderPost above |
alpha 1 |
m:TextureIndex(slot) |
the model's own texture id of mesh slot, 0..meshCount - 1; nothing outside that |
- |
m:SpawnSkillEffect(type, subType, scale, x, y, z) |
an effect owned by the monster, the way a skill's is - see Monster skills |
subType 0, scale 1, position the monster's |
m:TargetPosition() |
x, y, z of the monster's current target, nothing while it has none |
- |
m:ShootArrow(model, skill, x, y, z, r, g, b, angleX, angleY, angleZ) |
one arrow shot from a spot; model is what falls, skill decides what the client draws where it lands, and the arrow flies at whoever the server said this cast hit - see Monster skills
|
r, g, b the monster's own light; angles 90, a scatter of -10..10, 180 - the drop. angleX 270 sends it climbing instead, never landing |
m:SetWeapon(slot, cat, index) |
puts an item in this monster's hand, as <Weapon> does for every monster of the id: slot 0 right hand, 1 left; cat and index as in ItemList.xml. Without cat the hand is emptied |
- |
bodyLight* belongs to the model, so every instance of that model shares it -
the original value is restored after the render, so setting it per frame is correct
and setting it once is not.
renderFlags and bright exist only during OnRender. The flags and how the mask is
read are on Render flags.
Monster.WorldTime() is the same clock as Map.WorldTime().
Log.Add(text) and Log.AddC(color, text) print to the Lua console, dev mode only,
and are safe to leave in.
- Adding a monster - config, models, sounds
-
Monster skills - player skills on monsters,
SkillEffects.lua - Render flags - the body pass mask
-
Objects and the sandbox - the
oAPI and the shared files - Map scripts - the other half of the same Lua state
MuLua Scripting Plugin | Home
🖼️ Lua UI
- File map
- Creating windows
- Client and server
- Designing a window
- Protocol
- Scrolling and scrollbars
- Controls
- Custom text
- Text input
- Debug console
- Bitmap Slicer
- Encrypting scripts
Maps
Monsters
Shared
Version 1.14 | 2026-09-09